sidebar: Add several refinements (#51980)

Danilo Leal created

## Context

- Improve how we detect when the sidebar should render the empty state.
It was previously wrong using `content.entries.is_empty`, which would
also happen if there are no search matches.
- Improved archive view keyboard nav and design. Not using the ListItem
here anymore so as to avoid confusing hover and active states.
- Move archive and open folder buttons to the bottom of the sidebar.
- Add a new flavor of the recent projects for the sidebar that only
serves as a way to _add_ projects.
- Add the ability to add (and remove) folders to a given project group
in the sidebar through a dropdown menu
--- 
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

Change summary

assets/keymaps/default-linux.json                     |   1 
assets/keymaps/default-macos.json                     |   1 
assets/keymaps/default-windows.json                   |   1 
crates/agent_ui/src/agent_panel.rs                    |   4 
crates/agent_ui/src/threads_archive_view.rs           | 320 ++++---
crates/recent_projects/src/recent_projects.rs         |  26 
crates/recent_projects/src/sidebar_recent_projects.rs | 417 ++++++++++
crates/sidebar/src/sidebar.rs                         | 542 ++++++++++--
crates/title_bar/src/title_bar.rs                     |  85 +
crates/ui/src/components/label/highlighted_label.rs   |  36 
crates/ui/src/components/list/list_item.rs            |  35 
crates/workspace/src/multi_workspace.rs               |  28 
crates/workspace/src/workspace.rs                     |  10 
13 files changed, 1,171 insertions(+), 335 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -694,6 +694,7 @@
       "enter": "menu::Confirm",
       "space": "menu::Confirm",
       "ctrl-f": "agents_sidebar::FocusSidebarFilter",
+      "ctrl-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },

assets/keymaps/default-macos.json πŸ”—

@@ -760,6 +760,7 @@
       "enter": "menu::Confirm",
       "space": "menu::Confirm",
       "cmd-f": "agents_sidebar::FocusSidebarFilter",
+      "cmd-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },

assets/keymaps/default-windows.json πŸ”—

@@ -696,6 +696,7 @@
       "enter": "menu::Confirm",
       "space": "menu::Confirm",
       "ctrl-f": "agents_sidebar::FocusSidebarFilter",
+      "ctrl-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -2511,6 +2511,10 @@ impl AgentPanel {
             .is_some_and(|thread| !thread.read(cx).entries().is_empty())
     }
 
+    pub fn active_thread_is_draft(&self, cx: &App) -> bool {
+        self.active_conversation().is_some() && !self.active_thread_has_messages(cx)
+    }
+
     fn handle_first_send_requested(
         &mut self,
         thread_view: Entity<ThreadView>,

crates/agent_ui/src/threads_archive_view.rs πŸ”—

@@ -19,11 +19,12 @@ use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{AgentId, AgentServerStore};
 use theme::ActiveTheme;
 use ui::{
-    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, KeyBinding,
-    ListItem, PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel,
+    KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
     utils::platform_title_bar_height,
 };
 use util::ResultExt as _;
+use zed_actions::agents_sidebar::FocusSidebarFilter;
 use zed_actions::editor::{MoveDown, MoveUp};
 
 #[derive(Clone)]
@@ -162,6 +163,25 @@ impl ThreadsArchiveView {
                 }
             });
 
+        let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
+        cx.on_focus_in(
+            &filter_focus_handle,
+            window,
+            |this: &mut Self, _window, cx| {
+                if this.selection.is_some() {
+                    this.selection = None;
+                    cx.notify();
+                }
+            },
+        )
+        .detach();
+
+        cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
+            this.selection = None;
+            cx.notify();
+        })
+        .detach();
+
         let mut this = Self {
             agent_connection_store,
             agent_server_store,
@@ -185,6 +205,19 @@ impl ThreadsArchiveView {
         this
     }
 
+    pub fn has_selection(&self) -> bool {
+        self.selection.is_some()
+    }
+
+    pub fn clear_selection(&mut self) {
+        self.selection = None;
+    }
+
+    pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
+        let handle = self.filter_editor.read(cx).focus_handle(cx);
+        handle.focus(window, cx);
+    }
+
     fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
         self.selected_agent = agent.clone();
         self.is_loading = true;
@@ -287,11 +320,6 @@ impl ThreadsArchiveView {
         });
     }
 
-    fn go_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.reset_filter_editor_text(window, cx);
-        cx.emit(ThreadsArchiveViewEvent::Close);
-    }
-
     fn unarchive_thread(
         &mut self,
         session_info: AgentSessionInfo,
@@ -351,10 +379,16 @@ impl ThreadsArchiveView {
 
     fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
         self.select_next(&SelectNext, window, cx);
+        if self.selection.is_some() {
+            self.focus_handle.focus(window, cx);
+        }
     }
 
     fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
         self.select_previous(&SelectPrevious, window, cx);
+        if self.selection.is_some() {
+            self.focus_handle.focus(window, cx);
+        }
     }
 
     fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
@@ -369,24 +403,29 @@ impl ThreadsArchiveView {
         }
     }
 
-    fn select_previous(
-        &mut self,
-        _: &SelectPrevious,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let prev = match self.selection {
-            Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
+    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
+        match self.selection {
+            Some(ix) => {
+                if let Some(prev) = (ix > 0)
+                    .then(|| self.find_previous_selectable(ix - 1))
+                    .flatten()
+                {
+                    self.selection = Some(prev);
+                    self.list_state.scroll_to_reveal_item(prev);
+                } else {
+                    self.selection = None;
+                    self.focus_filter_editor(window, cx);
+                }
+                cx.notify();
+            }
             None => {
                 let last = self.items.len().saturating_sub(1);
-                self.find_previous_selectable(last)
+                if let Some(prev) = self.find_previous_selectable(last) {
+                    self.selection = Some(prev);
+                    self.list_state.scroll_to_reveal_item(prev);
+                    cx.notify();
+                }
             }
-            _ => return,
-        };
-        if let Some(prev) = prev {
-            self.selection = Some(prev);
-            self.list_state.scroll_to_reveal_item(prev);
-            cx.notify();
         }
     }
 
@@ -488,15 +527,27 @@ impl ThreadsArchiveView {
 
                 let highlight_positions = highlight_positions.clone();
                 let title_label = if highlight_positions.is_empty() {
-                    Label::new(title).truncate().into_any_element()
+                    Label::new(title).truncate().flex_1().into_any_element()
                 } else {
                     HighlightedLabel::new(title, highlight_positions)
                         .truncate()
+                        .flex_1()
                         .into_any_element()
                 };
 
-                ListItem::new(id)
-                    .focused(is_focused)
+                h_flex()
+                    .id(id)
+                    .min_w_0()
+                    .w_full()
+                    .px(DynamicSpacing::Base06.rems(cx))
+                    .border_1()
+                    .map(|this| {
+                        if is_focused {
+                            this.border_color(cx.theme().colors().border_focused)
+                        } else {
+                            this.border_color(gpui::transparent_black())
+                        }
+                    })
                     .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
                         if *is_hovered {
                             this.hovered_index = Some(ix);
@@ -509,9 +560,78 @@ impl ThreadsArchiveView {
                         v_flex()
                             .min_w_0()
                             .w_full()
-                            .py_1()
-                            .pl_1()
-                            .child(title_label)
+                            .p_1()
+                            .child(
+                                h_flex()
+                                    .min_w_0()
+                                    .w_full()
+                                    .gap_1()
+                                    .justify_between()
+                                    .child(title_label)
+                                    .when(hovered || is_focused, |this| {
+                                        this.child(
+                                            h_flex()
+                                                .gap_0p5()
+                                                .when(can_unarchive, |this| {
+                                                    this.child(
+                                                        Button::new("unarchive-thread", "Restore")
+                                                            .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.))
+                                                                    }),
+                                                                )
+                                                            })
+                                                            .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,
+                                                        )
+                                                        .icon_size(IconSize::Small)
+                                                        .icon_color(Color::Muted)
+                                                        .tooltip({
+                                                            move |_window, cx| {
+                                                                Tooltip::for_action_in(
+                                                                    "Delete Thread",
+                                                                    &RemoveSelectedThread,
+                                                                    &focus_handle,
+                                                                    cx,
+                                                                )
+                                                            }
+                                                        })
+                                                        .on_click(cx.listener(
+                                                            move |this, _, _, cx| {
+                                                                this.delete_thread(
+                                                                    &session_id_for_delete,
+                                                                    cx,
+                                                                );
+                                                                cx.stop_propagation();
+                                                            },
+                                                        )),
+                                                    )
+                                                }),
+                                        )
+                                    }),
+                            )
                             .child(
                                 h_flex()
                                     .gap_1()
@@ -537,58 +657,6 @@ impl ThreadsArchiveView {
                                     }),
                             ),
                     )
-                    .when(hovered || is_focused, |this| {
-                        this.end_slot(
-                            h_flex()
-                                .pr_2p5()
-                                .gap_0p5()
-                                .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.))),
-                                                )
-                                            })
-                                            .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)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted)
-                                            .tooltip({
-                                                move |_window, cx| {
-                                                    Tooltip::for_action_in(
-                                                        "Delete Thread",
-                                                        &RemoveSelectedThread,
-                                                        &focus_handle,
-                                                        cx,
-                                                    )
-                                                }
-                                            })
-                                            .on_click(cx.listener(move |this, _, _, cx| {
-                                                this.delete_thread(&session_id_for_delete, cx);
-                                                cx.stop_propagation();
-                                            })),
-                                    )
-                                }),
-                        )
-                    })
                     .into_any_element()
             }
         }
@@ -728,62 +796,52 @@ impl ThreadsArchiveView {
         let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
         let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
         let header_height = platform_title_bar_height(window);
-
-        v_flex()
-            .child(
-                h_flex()
-                    .h(header_height)
-                    .mt_px()
-                    .pb_px()
-                    .when(traffic_lights, |this| {
-                        this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
-                    })
-                    .pr_1p5()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .justify_between()
-                    .child(
-                        h_flex()
-                            .gap_1p5()
-                            .child(
-                                IconButton::new("back", IconName::ArrowLeft)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip(Tooltip::text("Back to Sidebar"))
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.go_back(window, cx);
-                                    })),
-                            )
-                            .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()),
-                    )
-                    .child(self.render_agent_picker(cx)),
-            )
+        let show_focus_keybinding =
+            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
+
+        h_flex()
+            .h(header_height)
+            .mt_px()
+            .pb_px()
+            .when(traffic_lights, |this| {
+                this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
+            })
+            .pr_1p5()
+            .gap_1()
+            .justify_between()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(Divider::vertical().color(ui::DividerColor::Border))
             .child(
                 h_flex()
-                    .h(Tab::container_height(cx))
-                    .px_1p5()
-                    .gap_1p5()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
+                    .ml_1()
+                    .min_w_0()
+                    .w_full()
+                    .gap_1()
                     .child(
-                        h_flex().size_4().flex_none().justify_center().child(
-                            Icon::new(IconName::MagnifyingGlass)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        ),
+                        Icon::new(IconName::MagnifyingGlass)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
                     )
-                    .child(self.filter_editor.clone())
-                    .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_items(cx);
-                                })),
-                        )
-                    }),
+                    .child(self.filter_editor.clone()),
             )
+            .when(show_focus_keybinding, |this| {
+                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
+            })
+            .when(!has_query && !show_focus_keybinding, |this| {
+                this.child(self.render_agent_picker(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_items(cx);
+                        })),
+                )
+            })
     }
 }
 

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -2,6 +2,7 @@ mod dev_container_suggest;
 pub mod disconnected_overlay;
 mod remote_connections;
 mod remote_servers;
+pub mod sidebar_recent_projects;
 mod ssh_config;
 
 use std::{
@@ -526,7 +527,7 @@ pub fn add_wsl_distro(
 pub struct RecentProjects {
     pub picker: Entity<Picker<RecentProjectsDelegate>>,
     rem_width: f32,
-    _subscription: Subscription,
+    _subscriptions: Vec<Subscription>,
 }
 
 impl ModalView for RecentProjects {
@@ -550,6 +551,7 @@ impl RecentProjects {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let style = delegate.style;
         let picker = cx.new(|cx| {
             Picker::list(delegate, window, cx)
                 .list_measure_all()
@@ -561,7 +563,21 @@ impl RecentProjects {
             picker.delegate.focus_handle = picker_focus_handle;
         });
 
-        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
+        let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))];
+
+        if style == ProjectPickerStyle::Popover {
+            let picker_focus = picker.focus_handle(cx);
+            subscriptions.push(
+                cx.on_focus_out(&picker_focus, window, |this, _, window, cx| {
+                    let submenu_focused = this.picker.update(cx, |picker, cx| {
+                        picker.delegate.actions_menu_handle.is_focused(window, cx)
+                    });
+                    if !submenu_focused {
+                        cx.emit(DismissEvent);
+                    }
+                }),
+            );
+        }
         // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
         // out workspace locations once the future runs to completion.
         let db = WorkspaceDb::global(cx);
@@ -585,7 +601,7 @@ impl RecentProjects {
         Self {
             picker,
             rem_width,
-            _subscription,
+            _subscriptions: subscriptions,
         }
     }
 
@@ -1635,7 +1651,7 @@ impl PickerDelegate for RecentProjectsDelegate {
     }
 }
 
-fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
+pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
     match options {
         None => IconName::Screen,
         Some(options) => match options {
@@ -1649,7 +1665,7 @@ fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> Icon
 }
 
 // Compute the highlighted text for the name and path
-fn highlights_for_path(
+pub(crate) fn highlights_for_path(
     path: &Path,
     match_positions: &Vec<usize>,
     path_start_offset: usize,

crates/recent_projects/src/sidebar_recent_projects.rs πŸ”—

@@ -0,0 +1,417 @@
+use std::collections::HashSet;
+use std::sync::Arc;
+
+use chrono::{DateTime, Utc};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    Subscription, Task, WeakEntity, Window,
+};
+use picker::{
+    Picker, PickerDelegate,
+    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
+};
+use remote::RemoteConnectionOptions;
+use settings::Settings;
+use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use ui_input::ErasedEditor;
+use util::{ResultExt, paths::PathExt};
+use workspace::{
+    MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb,
+    WorkspaceId, notifications::DetachAndPromptErr,
+};
+
+use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
+
+pub struct SidebarRecentProjects {
+    pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
+    _subscription: Subscription,
+}
+
+impl SidebarRecentProjects {
+    pub fn popover(
+        workspace: WeakEntity<Workspace>,
+        sibling_workspace_ids: HashSet<WorkspaceId>,
+        _focus_handle: FocusHandle,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Entity<Self> {
+        let fs = workspace
+            .upgrade()
+            .map(|ws| ws.read(cx).app_state().fs.clone());
+
+        cx.new(|cx| {
+            let delegate = SidebarRecentProjectsDelegate {
+                workspace,
+                sibling_workspace_ids,
+                workspaces: Vec::new(),
+                filtered_workspaces: Vec::new(),
+                selected_index: 0,
+                focus_handle: cx.focus_handle(),
+            };
+
+            let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
+                Picker::list(delegate, window, cx)
+                    .list_measure_all()
+                    .show_scrollbar(true)
+            });
+
+            let picker_focus_handle = picker.focus_handle(cx);
+            picker.update(cx, |picker, _| {
+                picker.delegate.focus_handle = picker_focus_handle;
+            });
+
+            let _subscription =
+                cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
+
+            let db = WorkspaceDb::global(cx);
+            cx.spawn_in(window, async move |this, cx| {
+                let Some(fs) = fs else { return };
+                let workspaces = db
+                    .recent_workspaces_on_disk(fs.as_ref())
+                    .await
+                    .log_err()
+                    .unwrap_or_default();
+                let workspaces =
+                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
+                this.update_in(cx, move |this, window, cx| {
+                    this.picker.update(cx, move |picker, cx| {
+                        picker.delegate.set_workspaces(workspaces);
+                        picker.update_matches(picker.query(cx), window, cx)
+                    })
+                })
+                .ok();
+            })
+            .detach();
+
+            picker.focus_handle(cx).focus(window, cx);
+
+            Self {
+                picker,
+                _subscription,
+            }
+        })
+    }
+}
+
+impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
+
+impl Focusable for SidebarRecentProjects {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for SidebarRecentProjects {
+    fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("SidebarRecentProjects")
+            .w(rems(18.))
+            .child(self.picker.clone())
+    }
+}
+
+pub struct SidebarRecentProjectsDelegate {
+    workspace: WeakEntity<Workspace>,
+    sibling_workspace_ids: HashSet<WorkspaceId>,
+    workspaces: Vec<(
+        WorkspaceId,
+        SerializedWorkspaceLocation,
+        PathList,
+        DateTime<Utc>,
+    )>,
+    filtered_workspaces: Vec<StringMatch>,
+    selected_index: usize,
+    focus_handle: FocusHandle,
+}
+
+impl SidebarRecentProjectsDelegate {
+    pub fn set_workspaces(
+        &mut self,
+        workspaces: Vec<(
+            WorkspaceId,
+            SerializedWorkspaceLocation,
+            PathList,
+            DateTime<Utc>,
+        )>,
+    ) {
+        self.workspaces = workspaces;
+    }
+}
+
+impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
+
+impl PickerDelegate for SidebarRecentProjectsDelegate {
+    type ListItem = AnyElement;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Search recent projects…".into()
+    }
+
+    fn render_editor(
+        &self,
+        editor: &Arc<dyn ErasedEditor>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Div {
+        h_flex()
+            .flex_none()
+            .h_9()
+            .px_2p5()
+            .justify_between()
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(editor.render(window, cx))
+    }
+
+    fn match_count(&self) -> usize {
+        self.filtered_workspaces.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let query = query.trim_start();
+        let smart_case = query.chars().any(|c| c.is_uppercase());
+        let is_empty_query = query.is_empty();
+
+        let current_workspace_id = self
+            .workspace
+            .upgrade()
+            .and_then(|ws| ws.read(cx).database_id());
+
+        let candidates: Vec<_> = self
+            .workspaces
+            .iter()
+            .enumerate()
+            .filter(|(_, (id, _, _, _))| {
+                Some(*id) != current_workspace_id && !self.sibling_workspace_ids.contains(id)
+            })
+            .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();
+
+        if is_empty_query {
+            self.filtered_workspaces = candidates
+                .into_iter()
+                .map(|candidate| StringMatch {
+                    candidate_id: candidate.id,
+                    score: 0.0,
+                    positions: Vec::new(),
+                    string: candidate.string,
+                })
+                .collect();
+        } else {
+            let mut matches = smol::block_on(fuzzy::match_strings(
+                &candidates,
+                query,
+                smart_case,
+                true,
+                100,
+                &Default::default(),
+                cx.background_executor().clone(),
+            ));
+            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))
+            });
+            self.filtered_workspaces = matches;
+        }
+
+        self.selected_index = 0;
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
+            return;
+        };
+        let Some((_, location, candidate_workspace_paths, _)) =
+            self.workspaces.get(hit.candidate_id)
+        else {
+            return;
+        };
+
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        match location {
+            SerializedWorkspaceLocation::Local => {
+                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
+                    let paths = candidate_workspace_paths.paths().to_vec();
+                    cx.defer(move |cx| {
+                        if let Some(task) = handle
+                            .update(cx, |multi_workspace, window, cx| {
+                                multi_workspace.open_project(paths, window, cx)
+                            })
+                            .log_err()
+                        {
+                            task.detach_and_log_err(cx);
+                        }
+                    });
+                }
+            }
+            SerializedWorkspaceLocation::Remote(connection) => {
+                let mut connection = connection.clone();
+                workspace.update(cx, |workspace, cx| {
+                    let app_state = workspace.app_state().clone();
+                    let replace_window = window.window_handle().downcast::<MultiWorkspace>();
+                    let open_options = OpenOptions {
+                        replace_window,
+                        ..Default::default()
+                    };
+                    if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
+                        crate::RemoteSettings::get_global(cx)
+                            .fill_connection_options_from_settings(connection);
+                    };
+                    let paths = candidate_workspace_paths.paths().to_vec();
+                    cx.spawn_in(window, async move |_, cx| {
+                        open_remote_project(connection.clone(), paths, app_state, open_options, cx)
+                            .await
+                    })
+                    .detach_and_prompt_err(
+                        "Failed to open project",
+                        window,
+                        cx,
+                        |_, _, _| None,
+                    );
+                });
+            }
+        }
+        cx.emit(DismissEvent);
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
+
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        let text = if self.workspaces.is_empty() {
+            "Recently opened projects will show up here"
+        } else {
+            "No matches"
+        };
+        Some(text.into())
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let hit = self.filtered_workspaces.get(ix)?;
+        let (_, location, paths, _) = self.workspaces.get(hit.candidate_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: Vec<_> = paths
+            .ordered_paths()
+            .map(|p| p.compact())
+            .map(|path| {
+                let (label, path_match) =
+                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
+                path_start_offset += path_match.text.len();
+                label
+            })
+            .collect();
+
+        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: Vec::new(),
+        };
+
+        let icon = icon_for_remote_connection(match location {
+            SerializedWorkspaceLocation::Local => None,
+            SerializedWorkspaceLocation::Remote(options) => Some(options),
+        });
+
+        Some(
+            ListItem::new(ix)
+                .toggle_state(selected)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .child(
+                    h_flex()
+                        .gap_3()
+                        .flex_grow()
+                        .child(Icon::new(icon).color(Color::Muted))
+                        .child(highlighted_match.render(window, cx)),
+                )
+                .tooltip(Tooltip::text(tooltip_path))
+                .into_any_element(),
+        )
+    }
+
+    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            v_flex()
+                .flex_1()
+                .p_1p5()
+                .gap_1()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child({
+                    let open_action = workspace::Open {
+                        create_new_window: false,
+                    };
+                    Button::new("open_local_folder", "Add Local Project")
+                        .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
+                        .on_click(move |_, window, cx| {
+                            window.dispatch_action(open_action.boxed_clone(), cx)
+                        })
+                })
+                .into_any(),
+        )
+    }
+}

crates/sidebar/src/sidebar.rs πŸ”—

@@ -17,24 +17,26 @@ use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
 };
 use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name};
-use recent_projects::RecentProjects;
+use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use ui::utils::platform_title_bar_height;
 
 use settings::Settings as _;
 use std::collections::{HashMap, HashSet};
 use std::mem;
 use std::path::Path;
+use std::rc::Rc;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
-    AgentThreadStatus, CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem,
-    PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
+    AgentThreadStatus, CommonAnimationExt, ContextMenu, 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, Open, Sidebar as WorkspaceSidebar,
-    ToggleWorkspaceSidebar, Workspace, WorkspaceId,
+    AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
+    Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
 };
 
 use zed_actions::OpenRecent;
@@ -47,6 +49,8 @@ gpui::actions!(
     [
         /// Creates a new thread in the currently selected or active project group.
         NewThreadInGroup,
+        /// Toggles between the thread list and the archive view.
+        ToggleArchive,
     ]
 );
 
@@ -143,6 +147,7 @@ struct SidebarContents {
     entries: Vec<ListEntry>,
     notified_threads: HashSet<acp::SessionId>,
     project_header_indices: Vec<usize>,
+    has_open_projects: bool,
 }
 
 impl SidebarContents {
@@ -239,11 +244,13 @@ pub struct Sidebar {
     /// loading. User actions may write directly for immediate feedback.
     focused_thread: Option<acp::SessionId>,
     agent_panel_visible: bool,
+    active_thread_is_draft: bool,
     hovered_thread_index: Option<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
     view: SidebarView,
-    recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
+    recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
+    project_header_menu_ix: Option<usize>,
     _subscriptions: Vec<gpui::Subscription>,
     _draft_observation: Option<gpui::Subscription>,
 }
@@ -329,11 +336,13 @@ impl Sidebar {
             selection: None,
             focused_thread: None,
             agent_panel_visible: false,
+            active_thread_is_draft: false,
             hovered_thread_index: None,
             collapsed_groups: HashSet::new(),
             expanded_groups: HashMap::new(),
             view: SidebarView::default(),
             recent_projects_popover_handle: PopoverMenuHandle::default(),
+            project_header_menu_ix: None,
             _subscriptions: Vec::new(),
             _draft_observation: None,
         }
@@ -613,6 +622,11 @@ impl Sidebar {
             .as_ref()
             .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
 
+        self.active_thread_is_draft = active_workspace
+            .as_ref()
+            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+            .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
+
         let previous = mem::take(&mut self.contents);
 
         let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
@@ -676,6 +690,10 @@ impl Sidebar {
             }
         }
 
+        let has_open_projects = workspaces
+            .iter()
+            .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
+
         for (ws_index, workspace) in workspaces.iter().enumerate() {
             if absorbed.contains_key(&ws_index) {
                 continue;
@@ -1024,6 +1042,7 @@ impl Sidebar {
             entries,
             notified_threads,
             project_header_indices,
+            has_open_projects,
         };
     }
 
@@ -1158,7 +1177,9 @@ impl Sidebar {
         } else {
             IconName::ChevronDown
         };
+
         let workspace_for_remove = workspace.clone();
+        let workspace_for_menu = workspace.clone();
 
         let path_list_for_toggle = path_list.clone();
         let path_list_for_collapse = path_list.clone();
@@ -1180,6 +1201,7 @@ impl Sidebar {
         };
 
         ListItem::new(id)
+            .height(Tab::content_height(cx))
             .group_name(group_name)
             .focused(is_selected)
             .child(
@@ -1187,7 +1209,6 @@ impl Sidebar {
                     .relative()
                     .min_w_0()
                     .w_full()
-                    .py_1()
                     .gap_1p5()
                     .child(
                         h_flex().size_4().flex_none().justify_center().child(
@@ -1224,27 +1245,21 @@ impl Sidebar {
                     }),
             )
             .end_hover_gradient_overlay(true)
-            .end_hover_slot(
+            .end_slot({
                 h_flex()
-                    .gap_1()
-                    .when(workspace_count > 1, |this| {
-                        this.child(
-                            IconButton::new(
-                                SharedString::from(format!(
-                                    "{id_prefix}project-header-remove-{ix}",
-                                )),
-                                IconName::Close,
-                            )
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .tooltip(Tooltip::text("Remove Project"))
-                            .on_click(cx.listener(
-                                move |this, _, window, cx| {
-                                    this.remove_workspace(&workspace_for_remove, window, cx);
-                                },
-                            )),
-                        )
+                    .when(self.project_header_menu_ix != Some(ix), |this| {
+                        this.visible_on_hover("list_item")
+                    })
+                    .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
+                        cx.stop_propagation();
                     })
+                    .child(self.render_project_header_menu(
+                        ix,
+                        id_prefix,
+                        &workspace_for_menu,
+                        &workspace_for_remove,
+                        cx,
+                    ))
                     .when(view_more_expanded && !is_collapsed, |this| {
                         this.child(
                             IconButton::new(
@@ -1265,8 +1280,27 @@ impl Sidebar {
                                 }
                             })),
                         )
-                    }),
-            )
+                    })
+                    .when(workspace_count > 1, |this| {
+                        let workspace_for_remove_btn = workspace_for_remove.clone();
+                        this.child(
+                            IconButton::new(
+                                SharedString::from(format!(
+                                    "{id_prefix}project-header-remove-{ix}",
+                                )),
+                                IconName::Close,
+                            )
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Remove Project"))
+                            .on_click(cx.listener(
+                                move |this, _, window, cx| {
+                                    this.remove_workspace(&workspace_for_remove_btn, window, cx);
+                                },
+                            )),
+                        )
+                    })
+            })
             .on_click(cx.listener(move |this, _, window, cx| {
                 this.selection = None;
                 this.toggle_collapse(&path_list_for_toggle, window, cx);
@@ -1279,6 +1313,140 @@ impl Sidebar {
             .into_any_element()
     }
 
+    fn render_project_header_menu(
+        &self,
+        ix: usize,
+        id_prefix: &str,
+        workspace: &Entity<Workspace>,
+        workspace_for_remove: &Entity<Workspace>,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let workspace_for_menu = workspace.clone();
+        let workspace_for_remove = workspace_for_remove.clone();
+        let multi_workspace = self.multi_workspace.clone();
+        let this = cx.weak_entity();
+
+        PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
+            .on_open(Rc::new({
+                let this = this.clone();
+                move |_window, cx| {
+                    this.update(cx, |sidebar, cx| {
+                        sidebar.project_header_menu_ix = Some(ix);
+                        cx.notify();
+                    })
+                    .ok();
+                }
+            }))
+            .menu(move |window, cx| {
+                let workspace = workspace_for_menu.clone();
+                let workspace_for_remove = workspace_for_remove.clone();
+                let multi_workspace = multi_workspace.clone();
+
+                let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
+                    let worktrees: Vec<_> = workspace
+                        .read(cx)
+                        .visible_worktrees(cx)
+                        .map(|worktree| {
+                            let worktree_read = worktree.read(cx);
+                            let id = worktree_read.id();
+                            let name: SharedString =
+                                worktree_read.root_name().as_unix_str().to_string().into();
+                            (id, name)
+                        })
+                        .collect();
+
+                    let worktree_count = worktrees.len();
+
+                    let mut menu = menu
+                        .header("Project Folders")
+                        .end_slot_action(Box::new(menu::EndSlot));
+
+                    for (worktree_id, name) in &worktrees {
+                        let worktree_id = *worktree_id;
+                        let workspace_for_worktree = workspace.clone();
+                        let workspace_for_remove_worktree = workspace_for_remove.clone();
+                        let multi_workspace_for_worktree = multi_workspace.clone();
+
+                        let remove_handler = move |window: &mut Window, cx: &mut App| {
+                            if worktree_count <= 1 {
+                                if let Some(mw) = multi_workspace_for_worktree.upgrade() {
+                                    let ws = workspace_for_remove_worktree.clone();
+                                    mw.update(cx, |multi_workspace, cx| {
+                                        if let Some(index) = multi_workspace
+                                            .workspaces()
+                                            .iter()
+                                            .position(|w| *w == ws)
+                                        {
+                                            multi_workspace.remove_workspace(index, window, cx);
+                                        }
+                                    });
+                                }
+                            } else {
+                                workspace_for_worktree.update(cx, |workspace, cx| {
+                                    workspace.project().update(cx, |project, cx| {
+                                        project.remove_worktree(worktree_id, cx);
+                                    });
+                                });
+                            }
+                        };
+
+                        menu = menu.entry_with_end_slot_on_hover(
+                            name.clone(),
+                            None,
+                            |_, _| {},
+                            IconName::Close,
+                            "Remove Folder".into(),
+                            remove_handler,
+                        );
+                    }
+
+                    let workspace_for_add = workspace.clone();
+                    let multi_workspace_for_add = multi_workspace.clone();
+                    menu.separator().entry(
+                        "Add Folder to Project",
+                        Some(Box::new(AddFolderToProject)),
+                        move |window, cx| {
+                            if let Some(mw) = multi_workspace_for_add.upgrade() {
+                                mw.update(cx, |mw, cx| {
+                                    mw.activate(workspace_for_add.clone(), cx);
+                                });
+                            }
+                            workspace_for_add.update(cx, |workspace, cx| {
+                                workspace.add_folder_to_project(&AddFolderToProject, window, cx);
+                            });
+                        },
+                    )
+                });
+
+                let this = this.clone();
+                window
+                    .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| {
+                        this.update(cx, |sidebar, cx| {
+                            sidebar.project_header_menu_ix = None;
+                            cx.notify();
+                        })
+                        .ok();
+                    })
+                    .detach();
+
+                Some(menu)
+            })
+            .trigger(
+                IconButton::new(
+                    SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")),
+                    IconName::Ellipsis,
+                )
+                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted),
+            )
+            .anchor(gpui::Corner::TopRight)
+            .offset(gpui::Point {
+                x: px(0.),
+                y: px(1.),
+            })
+    }
+
     fn render_sticky_header(
         &self,
         window: &mut Window,
@@ -1445,11 +1613,16 @@ impl Sidebar {
     }
 
     fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if matches!(self.view, SidebarView::Archive(_)) {
+        if !self.focus_handle.is_focused(window) {
             return;
         }
 
-        if self.selection.is_none() {
+        if let SidebarView::Archive(archive) = &self.view {
+            let has_selection = archive.read(cx).has_selection();
+            if !has_selection {
+                archive.update(cx, |view, cx| view.focus_filter_editor(window, cx));
+            }
+        } else if self.selection.is_none() {
             self.filter_editor.focus_handle(cx).focus(window, cx);
         }
     }
@@ -1471,7 +1644,14 @@ impl Sidebar {
         cx: &mut Context<Self>,
     ) {
         self.selection = None;
-        self.filter_editor.focus_handle(cx).focus(window, cx);
+        if let SidebarView::Archive(archive) = &self.view {
+            archive.update(cx, |view, cx| {
+                view.clear_selection();
+                view.focus_filter_editor(window, cx);
+            });
+        } else {
+            self.filter_editor.focus_handle(cx).focus(window, cx);
+        }
 
         // When vim mode is active, the editor defaults to normal mode which
         // blocks text input. Switch to insert mode so the user can type
@@ -2307,10 +2487,9 @@ impl Sidebar {
             .with_handle(popover_handle)
             .menu(move |window, cx| {
                 workspace.as_ref().map(|ws| {
-                    RecentProjects::popover(
+                    SidebarRecentProjects::popover(
                         ws.clone(),
                         sibling_workspace_ids.clone(),
-                        false,
                         focus_handle.clone(),
                         window,
                         cx,
@@ -2323,7 +2502,7 @@ impl Sidebar {
                     .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
                 |_window, cx| {
                     Tooltip::for_action(
-                        "Recent Projects",
+                        "Add Project",
                         &OpenRecent {
                             create_new_window: false,
                         },
@@ -2331,7 +2510,11 @@ impl Sidebar {
                     )
                 },
             )
-            .anchor(gpui::Corner::TopLeft)
+            .offset(gpui::Point {
+                x: px(-2.0),
+                y: px(-2.0),
+            })
+            .anchor(gpui::Corner::BottomRight)
     }
 
     fn render_view_more(
@@ -2448,14 +2631,8 @@ impl Sidebar {
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        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.active_thread_is_draft
             && self
                 .multi_workspace
                 .upgrade()
@@ -2471,7 +2648,7 @@ impl Sidebar {
         let workspace = workspace.clone();
         let id = SharedString::from(format!("new-thread-btn-{}", ix));
 
-        ThreadItem::new(id, label)
+        let thread_item = ThreadItem::new(id, label)
             .icon(IconName::Plus)
             .selected(is_active)
             .focused(is_selected)
@@ -2481,8 +2658,39 @@ impl Sidebar {
                     this.selection = None;
                     this.create_new_thread(&workspace, window, cx);
                 }))
-            })
-            .into_any_element()
+            });
+
+        if is_active {
+            div()
+                .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
+                    cx.stop_propagation();
+                })
+                .child(thread_item)
+                .into_any_element()
+        } else {
+            thread_item.into_any_element()
+        }
+    }
+
+    fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_query = self.has_filter_query(cx);
+        let message = if has_query {
+            "No threads match your search."
+        } else {
+            "No threads yet"
+        };
+
+        v_flex()
+            .id("sidebar-no-results")
+            .p_4()
+            .size_full()
+            .items_center()
+            .justify_center()
+            .child(
+                Label::new(message)
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
     }
 
     fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -2527,7 +2735,7 @@ impl Sidebar {
 
     fn render_sidebar_header(
         &self,
-        empty_state: bool,
+        no_open_projects: bool,
         window: &Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
@@ -2535,69 +2743,47 @@ impl Sidebar {
         let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
         let header_height = platform_title_bar_height(window);
 
-        v_flex()
-            .child(
-                h_flex()
-                    .h(header_height)
-                    .mt_px()
-                    .pb_px()
-                    .when(traffic_lights, |this| {
-                        this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
-                    })
-                    .pr_1p5()
-                    .gap_1()
-                    .border_b_1()
+        h_flex()
+            .h(header_height)
+            .mt_px()
+            .pb_px()
+            .when(traffic_lights, |this| {
+                this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
+            })
+            .pr_1p5()
+            .gap_1()
+            .when(!no_open_projects, |this| {
+                this.border_b_1()
                     .border_color(cx.theme().colors().border)
-                    .justify_end()
+                    .child(Divider::vertical().color(ui::DividerColor::Border))
                     .child(
-                        IconButton::new("archive", IconName::Archive)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("View Archived Threads"))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.show_archive(window, cx);
-                            })),
+                        div().ml_1().child(
+                            Icon::new(IconName::MagnifyingGlass)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
                     )
-                    .child(self.render_recent_projects_button(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))
-                                    },
+                    .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(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(cx);
-                                            })),
-                                    )
-                                }),
-                        ),
-                )
+                            }),
+                    )
             })
     }
 
@@ -2633,6 +2819,13 @@ impl Sidebar {
 }
 
 impl Sidebar {
+    fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
+        match &self.view {
+            SidebarView::ThreadList => self.show_archive(window, cx),
+            SidebarView::Archive(_) => self.show_thread_list(window, cx),
+        }
+    }
+
     fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
             w.read(cx)
@@ -2685,14 +2878,16 @@ impl Sidebar {
         );
 
         self._subscriptions.push(subscription);
-        self.view = SidebarView::Archive(archive_view);
+        self.view = SidebarView::Archive(archive_view.clone());
+        archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
         cx.notify();
     }
 
     fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.view = SidebarView::ThreadList;
         self._subscriptions.clear();
-        window.focus(&self.focus_handle, cx);
+        let handle = self.filter_editor.read(cx).focus_handle(cx);
+        handle.focus(window, cx);
         cx.notify();
     }
 }
@@ -2711,14 +2906,6 @@ impl WorkspaceSidebar for Sidebar {
         !self.contents.notified_threads.is_empty()
     }
 
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        self.recent_projects_popover_handle.toggle(window, cx);
-    }
-
-    fn is_recent_projects_popover_deployed(&self) -> bool {
-        self.recent_projects_popover_handle.is_deployed()
-    }
-
     fn is_threads_list_view_active(&self) -> bool {
         matches!(self.view, SidebarView::ThreadList)
     }
@@ -2746,7 +2933,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();
+        let no_open_projects = !self.contents.has_open_projects;
+        let no_search_results = self.contents.entries.is_empty();
 
         v_flex()
             .id("workspace-sidebar")
@@ -2767,7 +2955,11 @@ impl Render for Sidebar {
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::remove_selected_thread))
             .on_action(cx.listener(Self::new_thread_in_group))
+            .on_action(cx.listener(Self::toggle_archive))
             .on_action(cx.listener(Self::focus_sidebar_filter))
+            .on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
+                this.recent_projects_popover_handle.toggle(window, cx);
+            }))
             .font(ui_font)
             .h_full()
             .w(self.width)
@@ -2776,9 +2968,9 @@ impl Render for Sidebar {
             .border_color(cx.theme().colors().border)
             .map(|this| match &self.view {
                 SidebarView::ThreadList => this
-                    .child(self.render_sidebar_header(empty_state, window, cx))
+                    .child(self.render_sidebar_header(no_open_projects, window, cx))
                     .map(|this| {
-                        if empty_state {
+                        if no_open_projects {
                             this.child(self.render_empty_state(cx))
                         } else {
                             this.child(
@@ -2794,6 +2986,9 @@ impl Render for Sidebar {
                                         .flex_1()
                                         .size_full(),
                                     )
+                                    .when(no_search_results, |this| {
+                                        this.child(self.render_no_results(cx))
+                                    })
                                     .when_some(sticky_header, |this, header| this.child(header))
                                     .vertical_scrollbar_for(&self.list_state, window, cx),
                             )
@@ -2804,9 +2999,31 @@ impl Render for Sidebar {
             .child(
                 h_flex()
                     .p_1()
+                    .gap_1()
+                    .justify_between()
                     .border_t_1()
                     .border_color(cx.theme().colors().border)
-                    .child(self.render_sidebar_toggle_button(cx)),
+                    .child(self.render_sidebar_toggle_button(cx))
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .child(self.render_recent_projects_button(cx))
+                            .child(
+                                IconButton::new("archive", IconName::Archive)
+                                    .icon_size(IconSize::Small)
+                                    .toggle_state(matches!(self.view, SidebarView::Archive(..)))
+                                    .tooltip(move |_, cx| {
+                                        Tooltip::for_action(
+                                            "Toggle Archived Threads",
+                                            &ToggleArchive,
+                                            cx,
+                                        )
+                                    })
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.toggle_archive(&ToggleArchive, window, cx);
+                                    })),
+                            ),
+                    ),
             )
     }
 }
@@ -4938,6 +5155,93 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) {
+        let project = init_test_project_with_agent_panel("/project-a", cx).await;
+        let fs = cx.update(|cx| <dyn fs::Fs>::global(cx));
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+
+        let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+
+        // Start a thread and send a message so it has history.
+        let connection = StubAgentConnection::new();
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Done".into()),
+        )]);
+        open_thread_with_connection(&panel, connection, cx);
+        send_message(&panel, cx);
+        let session_id = active_session_id(&panel, cx);
+        save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
+        cx.run_until_parked();
+
+        // Verify the thread appears in the sidebar.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project-a]", "  [+ New Thread]", "  Hello *",]
+        );
+
+        // The "New Thread" button should NOT be in "active/draft" state
+        // because the panel has a thread with messages.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert!(
+                !sidebar.active_thread_is_draft,
+                "Panel has a thread with messages, so it should not be a draft"
+            );
+        });
+
+        // Now add a second folder to the workspace, changing the path_list.
+        fs.as_fake()
+            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_worktree("/project-b", true, cx)
+            })
+            .await
+            .expect("should add worktree");
+        cx.run_until_parked();
+
+        // The workspace path_list is now [project-a, project-b]. The old
+        // thread was stored under [project-a], so it no longer appears in
+        // the sidebar list for this workspace.
+        let entries = visible_entries_as_strings(&sidebar, cx);
+        assert!(
+            !entries.iter().any(|e| e.contains("Hello")),
+            "Thread stored under the old path_list should not appear: {:?}",
+            entries
+        );
+
+        // The "New Thread" button must still be clickable (not stuck in
+        // "active/draft" state). Verify that `active_thread_is_draft` is
+        // false β€” the panel still has the old thread with messages.
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert!(
+                !sidebar.active_thread_is_draft,
+                "After adding a folder the panel still has a thread with messages, \
+                 so active_thread_is_draft should be false"
+            );
+        });
+
+        // Actually click "New Thread" by calling create_new_thread and
+        // verify a new draft is created.
+        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.create_new_thread(&workspace, window, cx);
+        });
+        cx.run_until_parked();
+
+        // After creating a new thread, the panel should now be in draft
+        // state (no messages on the new thread).
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert!(
+                sidebar.active_thread_is_draft,
+                "After creating a new thread the panel should be in draft state"
+            );
+        });
+    }
+
     async fn init_test_project_with_git(
         worktree_path: &str,
         cx: &mut TestAppContext,

crates/title_bar/src/title_bar.rs πŸ”—

@@ -743,7 +743,7 @@ impl TitleBar {
 
         if is_sidebar_open && is_threads_list_view_active {
             return self
-                .render_project_name_with_sidebar_popover(display_name, is_project_selected, cx)
+                .render_recent_projects_popover(display_name, is_project_selected, cx)
                 .into_any_element();
         }
 
@@ -802,51 +802,66 @@ impl TitleBar {
             .into_any_element()
     }
 
-    /// When the sidebar is open, the title bar's project name button becomes a
-    /// plain button that toggles the sidebar's popover (so the popover is always
-    /// anchored to the sidebar). Both buttons show their selected state together.
-    fn render_project_name_with_sidebar_popover(
+    fn render_recent_projects_popover(
         &self,
         display_name: String,
         is_project_selected: bool,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let multi_workspace = self.multi_workspace.clone();
+        let workspace = self.workspace.clone();
+
+        let focus_handle = workspace
+            .upgrade()
+            .map(|w| w.read(cx).focus_handle(cx))
+            .unwrap_or_else(|| cx.focus_handle());
 
-        let is_popover_deployed = multi_workspace
+        let sibling_workspace_ids: HashSet<WorkspaceId> = self
+            .multi_workspace
             .as_ref()
             .and_then(|mw| mw.upgrade())
-            .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx))
-            .unwrap_or(false);
-
-        Button::new("project_name_trigger", display_name)
-            .label_size(LabelSize::Small)
-            .when(self.worktree_count(cx) > 1, |this| {
-                this.end_icon(
-                    Icon::new(IconName::ChevronDown)
-                        .size(IconSize::XSmall)
-                        .color(Color::Muted),
-                )
+            .map(|mw| {
+                mw.read(cx)
+                    .workspaces()
+                    .iter()
+                    .filter_map(|ws| ws.read(cx).database_id())
+                    .collect()
             })
-            .toggle_state(is_popover_deployed)
-            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-            .when(!is_project_selected, |s| s.color(Color::Muted))
-            .tooltip(move |_window, cx| {
-                Tooltip::for_action(
-                    "Recent Projects",
-                    &zed_actions::OpenRecent {
-                        create_new_window: false,
-                    },
+            .unwrap_or_default();
+
+        PopoverMenu::new("sidebar-title-recent-projects-menu")
+            .menu(move |window, cx| {
+                Some(recent_projects::RecentProjects::popover(
+                    workspace.clone(),
+                    sibling_workspace_ids.clone(),
+                    false,
+                    focus_handle.clone(),
+                    window,
                     cx,
-                )
-            })
-            .on_click(move |_, window, cx| {
-                if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) {
-                    mw.update(cx, |mw, cx| {
-                        mw.toggle_recent_projects_popover(window, cx);
-                    });
-                }
+                ))
             })
+            .trigger_with_tooltip(
+                Button::new("project_name_trigger", display_name)
+                    .label_size(LabelSize::Small)
+                    .when(self.worktree_count(cx) > 1, |this| {
+                        this.end_icon(
+                            Icon::new(IconName::ChevronDown)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
+                    })
+                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                    .when(!is_project_selected, |s| s.color(Color::Muted)),
+                move |_window, cx| {
+                    Tooltip::for_action(
+                        "Recent Projects",
+                        &zed_actions::OpenRecent {
+                            create_new_window: false,
+                        },
+                        cx,
+                    )
+                },
+            )
+            .anchor(gpui::Corner::TopLeft)
     }
 
     fn render_project_branch(

crates/ui/src/components/label/highlighted_label.rs πŸ”—

@@ -1,6 +1,6 @@
 use std::ops::Range;
 
-use gpui::{FontWeight, HighlightStyle, StyledText};
+use gpui::{FontWeight, HighlightStyle, StyleRefinement, StyledText};
 
 use crate::{LabelCommon, LabelLike, LabelSize, LineHeightStyle, prelude::*};
 
@@ -38,6 +38,40 @@ impl HighlightedLabel {
     }
 }
 
+impl HighlightedLabel {
+    fn style(&mut self) -> &mut StyleRefinement {
+        self.base.base.style()
+    }
+
+    pub fn flex_1(mut self) -> Self {
+        self.style().flex_grow = Some(1.);
+        self.style().flex_shrink = Some(1.);
+        self.style().flex_basis = Some(gpui::relative(0.).into());
+        self
+    }
+
+    pub fn flex_none(mut self) -> Self {
+        self.style().flex_grow = Some(0.);
+        self.style().flex_shrink = Some(0.);
+        self
+    }
+
+    pub fn flex_grow(mut self) -> Self {
+        self.style().flex_grow = Some(1.);
+        self
+    }
+
+    pub fn flex_shrink(mut self) -> Self {
+        self.style().flex_shrink = Some(1.);
+        self
+    }
+
+    pub fn flex_shrink_0(mut self) -> Self {
+        self.style().flex_shrink = Some(0.);
+        self
+    }
+}
+
 impl LabelCommon for HighlightedLabel {
     fn size(mut self, size: LabelSize) -> Self {
         self.base = self.base.size(size);

crates/ui/src/components/list/list_item.rs πŸ”—

@@ -49,6 +49,7 @@ pub struct ListItem {
     overflow_x: bool,
     focused: Option<bool>,
     docked_right: bool,
+    height: Option<Pixels>,
 }
 
 impl ListItem {
@@ -80,6 +81,7 @@ impl ListItem {
             overflow_x: false,
             focused: None,
             docked_right: false,
+            height: None,
         }
     }
 
@@ -201,6 +203,11 @@ impl ListItem {
         self.docked_right = docked_right;
         self
     }
+
+    pub fn height(mut self, height: Pixels) -> Self {
+        self.height = Some(height);
+        self
+    }
 }
 
 impl Disableable for ListItem {
@@ -244,6 +251,7 @@ impl RenderOnce for ListItem {
             .id(self.id)
             .when_some(self.group_name, |this, group| this.group(group))
             .w_full()
+            .when_some(self.height, |this, height| this.h(height))
             .relative()
             // When an item is inset draw the indent spacing outside of the item
             .when(self.inset, |this| {
@@ -285,26 +293,21 @@ impl RenderOnce for ListItem {
                         ListItemSpacing::Sparse => this.py_1(),
                     })
                     .when(self.inset && !self.disabled, |this| {
-                        this
-                            // TODO: Add focus state
-                            //.when(self.state == InteractionState::Focused, |this| {
-                            .when_some(self.focused, |this, focused| {
-                                if focused {
-                                    this.border_1()
-                                        .border_color(cx.theme().colors().border_focused)
-                                } else {
-                                    this.border_1()
-                                }
-                            })
-                            .when(self.selectable, |this| {
-                                this.hover(|style| {
-                                    style.bg(cx.theme().colors().ghost_element_hover)
-                                })
+                        this.when_some(self.focused, |this, focused| {
+                            if focused {
+                                this.border_1()
+                                    .border_color(cx.theme().colors().border_focused)
+                            } else {
+                                this.border_1()
+                            }
+                        })
+                        .when(self.selectable, |this| {
+                            this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
                                 .active(|style| style.bg(cx.theme().colors().ghost_element_active))
                                 .when(self.selected, |this| {
                                     this.bg(cx.theme().colors().ghost_element_selected)
                                 })
-                            })
+                        })
                     })
                     .when_some(
                         self.on_click.filter(|_| !self.disabled),

crates/workspace/src/multi_workspace.rs πŸ”—

@@ -41,8 +41,7 @@ pub trait Sidebar: Focusable + Render + Sized {
     fn width(&self, cx: &App) -> Pixels;
     fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
     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
     }
@@ -59,8 +58,7 @@ pub trait SidebarHandle: 'static + Send + Sync {
     fn has_notifications(&self, cx: &App) -> bool;
     fn to_any(&self) -> AnyView;
     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;
 }
 
@@ -107,16 +105,6 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
         Entity::entity_id(self)
     }
 
-    fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        self.update(cx, |this, cx| {
-            this.toggle_recent_projects_popover(window, cx);
-        });
-    }
-
-    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()
     }
@@ -203,18 +191,6 @@ impl MultiWorkspace {
             .map_or(false, |s| s.has_notifications(cx))
     }
 
-    pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
-        if let Some(sidebar) = &self.sidebar {
-            sidebar.toggle_recent_projects_popover(window, cx);
-        }
-    }
-
-    pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
-        self.sidebar
-            .as_ref()
-            .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()

crates/workspace/src/workspace.rs πŸ”—

@@ -1419,7 +1419,13 @@ impl Workspace {
                     this.collaborator_left(*peer_id, window, cx);
                 }
 
-                &project::Event::WorktreeRemoved(id) | &project::Event::WorktreeAdded(id) => {
+                &project::Event::WorktreeRemoved(_) => {
+                    this.update_window_title(window, cx);
+                    this.serialize_workspace(window, cx);
+                    this.update_history(cx);
+                }
+
+                &project::Event::WorktreeAdded(id) => {
                     this.update_window_title(window, cx);
                     if this
                         .project()
@@ -3366,7 +3372,7 @@ impl Workspace {
             .map(|wt| wt.read(cx).abs_path().as_ref().to_path_buf())
     }
 
-    fn add_folder_to_project(
+    pub fn add_folder_to_project(
         &mut self,
         _: &AddFolderToProject,
         window: &mut Window,