agent: Improve sidebar design and behavior (#51763)

Danilo Leal and cameron created

- Selection/focus improvements (reduces flickers, move selection more
correctly throughout the list)
- Adds open folder button in the sidebar header
- Fixes sidebar header design, including the thread view one, too
- Fixes behavior of cmd-n when focused in the sidebar
- Changes the design for the "new thread" button in the sidebar and adds
a preview of the prompt on it
- Rename items in the "start thread in" dropdown

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>

Change summary

Cargo.lock                                           |   1 
assets/keymaps/default-linux.json                    |   2 
assets/keymaps/default-macos.json                    |   2 
assets/keymaps/default-windows.json                  |   2 
crates/agent_ui/src/agent_panel.rs                   |  23 
crates/agent_ui/src/threads_archive_view.rs          |  63 
crates/sidebar/Cargo.toml                            |   1 
crates/sidebar/src/sidebar.rs                        | 751 +++++++++----
crates/title_bar/src/title_bar.rs                    | 100 +
crates/ui/src/components/ai.rs                       |   2 
crates/ui/src/components/ai/thread_item.rs           |  46 
crates/ui/src/components/ai/thread_sidebar_toggle.rs | 177 ---
crates/workspace/src/multi_workspace.rs              |  26 
13 files changed, 728 insertions(+), 468 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -15870,6 +15870,7 @@ dependencies = [
  "pretty_assertions",
  "project",
  "prompt_store",
+ "recent_projects",
  "serde_json",
  "settings",
  "theme",

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

@@ -674,7 +674,7 @@
     "context": "ThreadsSidebar",
     "use_key_equivalents": true,
     "bindings": {
-      "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+      "ctrl-n": "agents_sidebar::NewThreadInGroup",
       "left": "agents_sidebar::CollapseSelectedEntry",
       "right": "agents_sidebar::ExpandSelectedEntry",
       "enter": "menu::Confirm",

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

@@ -742,7 +742,7 @@
     "context": "ThreadsSidebar",
     "use_key_equivalents": true,
     "bindings": {
-      "cmd-n": "multi_workspace::NewWorkspaceInWindow",
+      "cmd-n": "agents_sidebar::NewThreadInGroup",
       "left": "agents_sidebar::CollapseSelectedEntry",
       "right": "agents_sidebar::ExpandSelectedEntry",
       "enter": "menu::Confirm",

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

@@ -678,7 +678,7 @@
     "context": "ThreadsSidebar",
     "use_key_equivalents": true,
     "bindings": {
-      "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+      "ctrl-n": "agents_sidebar::NewThreadInGroup",
       "left": "agents_sidebar::CollapseSelectedEntry",
       "right": "agents_sidebar::ExpandSelectedEntry",
       "enter": "menu::Confirm",

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

@@ -601,8 +601,8 @@ impl From<Agent> for AgentType {
 impl StartThreadIn {
     fn label(&self) -> SharedString {
         match self {
-            Self::LocalProject => "Current Project".into(),
-            Self::NewWorktree => "New Worktree".into(),
+            Self::LocalProject => "Current Worktree".into(),
+            Self::NewWorktree => "New Git Worktree".into(),
         }
     }
 }
@@ -1951,6 +1951,21 @@ impl AgentPanel {
         self.background_threads.contains_key(session_id)
     }
 
+    pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
+        let conversation_views = self
+            .active_conversation_view()
+            .into_iter()
+            .chain(self.background_threads.values());
+
+        for conversation_view in conversation_views {
+            if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
+                thread_view.update(cx, |view, cx| view.cancel_generation(cx));
+                return true;
+            }
+        }
+        false
+    }
+
     /// active thread plus any background threads that are still running or
     /// completed but unseen.
     pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
@@ -3551,7 +3566,7 @@ impl AgentPanel {
 
                     menu.header("Start Thread In…")
                         .item(
-                            ContextMenuEntry::new("Current Project")
+                            ContextMenuEntry::new("Current Worktree")
                                 .toggleable(IconPosition::End, is_local_selected)
                                 .documentation_aside(documentation_side, move |_| {
                                     HoldForDefault::new(is_local_default)
@@ -3579,7 +3594,7 @@ impl AgentPanel {
                                 }),
                         )
                         .item({
-                            let entry = ContextMenuEntry::new("New Worktree")
+                            let entry = ContextMenuEntry::new("New Git Worktree")
                                 .toggleable(IconPosition::End, is_new_worktree_selected)
                                 .disabled(new_worktree_disabled)
                                 .handler({

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

@@ -21,6 +21,7 @@ use theme::ActiveTheme;
 use ui::{
     ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
     PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+    utils::platform_title_bar_height,
 };
 use util::ResultExt as _;
 use zed_actions::editor::{MoveDown, MoveUp};
@@ -676,32 +677,56 @@ impl ThreadsArchiveView {
             })
     }
 
-    fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
         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);
 
-        h_flex()
-            .h(Tab::container_height(cx))
-            .px_1()
-            .justify_between()
-            .border_b_1()
-            .border_color(cx.theme().colors().border)
+        v_flex()
             .child(
                 h_flex()
-                    .flex_1()
-                    .w_full()
+                    .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)),
+            )
+            .child(
+                h_flex()
+                    .h(Tab::container_height(cx))
+                    .p_2()
+                    .pr_1p5()
                     .gap_1p5()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
                     .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);
-                            })),
+                        Icon::new(IconName::MagnifyingGlass)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
                     )
                     .child(self.filter_editor.clone())
                     .when(has_query, |this| {
-                        this.border_r_1().child(
-                            IconButton::new("clear_archive_filter", IconName::Close)
+                        this.child(
+                            IconButton::new("clear_filter", IconName::Close)
                                 .icon_size(IconSize::Small)
                                 .tooltip(Tooltip::text("Clear Search"))
                                 .on_click(cx.listener(|this, _, window, cx| {
@@ -711,7 +736,6 @@ impl ThreadsArchiveView {
                         )
                     }),
             )
-            .child(self.render_agent_picker(cx))
     }
 }
 
@@ -783,8 +807,7 @@ impl Render for ThreadsArchiveView {
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::remove_selected_thread))
             .size_full()
-            .bg(cx.theme().colors().surface_background)
-            .child(self.render_header(cx))
+            .child(self.render_header(window, cx))
             .child(content)
     }
 }

crates/sidebar/Cargo.toml πŸ”—

@@ -28,6 +28,7 @@ fs.workspace = true
 gpui.workspace = true
 menu.workspace = true
 project.workspace = true
+recent_projects.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true

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

@@ -14,6 +14,8 @@ use gpui::{
 };
 use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{AgentId, Event as ProjectEvent};
+use recent_projects::RecentProjects;
+use ui::utils::platform_title_bar_height;
 
 use std::collections::{HashMap, HashSet};
 use std::mem;
@@ -21,16 +23,17 @@ use std::path::Path;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
-    AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
-    Tooltip, WithScrollbar, prelude::*,
+    AgentThreadStatus, ButtonStyle, HighlightedLabel, KeyBinding, ListItem, PopoverMenu,
+    PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use util::path_list::PathList;
 use workspace::{
-    MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar,
-    Workspace,
+    FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
+    ToggleWorkspaceSidebar, Workspace,
 };
 
+use zed_actions::OpenRecent;
 use zed_actions::editor::{MoveDown, MoveUp};
 
 actions!(
@@ -40,6 +43,8 @@ actions!(
         CollapseSelectedEntry,
         /// Expands the selected entry in the workspace sidebar.
         ExpandSelectedEntry,
+        /// Creates a new thread in the currently selected or active project group.
+        NewThreadInGroup,
     ]
 );
 
@@ -110,7 +115,6 @@ enum ListEntry {
         label: SharedString,
         workspace: Entity<Workspace>,
         highlight_positions: Vec<usize>,
-        has_threads: bool,
     },
     Thread(ThreadEntry),
     ViewMore {
@@ -222,14 +226,22 @@ pub struct Sidebar {
     /// Note: This is NOT the same as the active item.
     selection: Option<usize>,
     focused_thread: Option<acp::SessionId>,
+    /// 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
+    /// that should not reset the user's thread focus the way an explicit
+    /// workspace switch does.
+    pending_workspace_removal: bool,
     active_entry_index: Option<usize>,
     hovered_thread_index: Option<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
     view: SidebarView,
     archive_view: Option<Entity<ThreadsArchiveView>>,
+    recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
     _subscriptions: Vec<gpui::Subscription>,
     _update_entries_task: Option<gpui::Task<()>>,
+    _draft_observation: Option<gpui::Subscription>,
 }
 
 impl Sidebar {
@@ -253,7 +265,29 @@ impl Sidebar {
             window,
             |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
-                    this.focused_thread = None;
+                    // Don't clear focused_thread when the active workspace
+                    // changed because a workspace was removed β€” the focused
+                    // thread may still be valid in the new active workspace.
+                    // Only clear it for explicit user-initiated switches.
+                    if mem::take(&mut this.pending_workspace_removal) {
+                        // If the removed workspace had no focused thread, seed
+                        // from the new active panel so its current thread gets
+                        // highlighted β€” same logic as subscribe_to_workspace.
+                        if this.focused_thread.is_none() {
+                            if let Some(mw) = this.multi_workspace.upgrade() {
+                                let ws = mw.read(cx).workspace();
+                                if let Some(panel) = ws.read(cx).panel::<AgentPanel>(cx) {
+                                    this.focused_thread = panel
+                                        .read(cx)
+                                        .active_conversation()
+                                        .and_then(|cv| cv.read(cx).parent_id(cx));
+                                }
+                            }
+                        }
+                    } else {
+                        this.focused_thread = None;
+                    }
+                    this.observe_draft_editor(cx);
                     this.update_entries(false, cx);
                 }
                 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -261,6 +295,9 @@ impl Sidebar {
                     this.update_entries(false, cx);
                 }
                 MultiWorkspaceEvent::WorkspaceRemoved(_) => {
+                    // Signal that the upcoming ActiveWorkspaceChanged event is
+                    // a consequence of this removal, not a user workspace switch.
+                    this.pending_workspace_removal = true;
                     this.update_entries(false, cx);
                 }
             },
@@ -306,18 +343,21 @@ impl Sidebar {
             contents: SidebarContents::default(),
             selection: None,
             focused_thread: None,
+            pending_workspace_removal: false,
             active_entry_index: None,
             hovered_thread_index: None,
             collapsed_groups: HashSet::new(),
             expanded_groups: HashMap::new(),
             view: SidebarView::default(),
             archive_view: None,
+            recent_projects_popover_handle: PopoverMenuHandle::default(),
             _subscriptions: Vec::new(),
+            _draft_observation: None,
         }
     }
 
     fn subscribe_to_workspace(
-        &self,
+        &mut self,
         workspace: &Entity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -372,11 +412,19 @@ impl Sidebar {
 
         if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
             self.subscribe_to_agent_panel(&agent_panel, window, 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).
+            self.focused_thread = agent_panel
+                .read(cx)
+                .active_conversation()
+                .and_then(|cv| cv.read(cx).parent_id(cx));
+            self.observe_draft_editor(cx);
         }
     }
 
     fn subscribe_to_agent_panel(
-        &self,
+        &mut self,
         agent_panel: &Entity<AgentPanel>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -384,32 +432,114 @@ impl Sidebar {
         cx.subscribe_in(
             agent_panel,
             window,
-            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
-                AgentPanelEvent::ActiveViewChanged => {
-                    this.focused_thread = agent_panel
-                        .read(cx)
-                        .active_conversation()
-                        .and_then(|cv| cv.read(cx).parent_id(cx));
-                    this.update_entries(false, cx);
-                }
-                AgentPanelEvent::ThreadFocused => {
-                    let new_focused = agent_panel
-                        .read(cx)
-                        .active_conversation()
-                        .and_then(|cv| cv.read(cx).parent_id(cx));
-                    if new_focused.is_some() && new_focused != this.focused_thread {
-                        this.focused_thread = new_focused;
+            |this, agent_panel, event: &AgentPanelEvent, _window, cx| {
+                // Check whether the panel that emitted this event belongs to
+                // the currently active workspace. Only the active workspace's
+                // panel should drive focused_thread β€” otherwise running threads
+                // in background workspaces would continuously overwrite it,
+                // causing the selection highlight to jump around.
+                let is_active_panel = this
+                    .multi_workspace
+                    .upgrade()
+                    .and_then(|mw| mw.read(cx).workspace().read(cx).panel::<AgentPanel>(cx))
+                    .is_some_and(|active_panel| active_panel == *agent_panel);
+
+                match event {
+                    AgentPanelEvent::ActiveViewChanged => {
+                        if is_active_panel {
+                            this.focused_thread = agent_panel
+                                .read(cx)
+                                .active_conversation()
+                                .and_then(|cv| cv.read(cx).parent_id(cx));
+                            this.observe_draft_editor(cx);
+                        }
+                        this.update_entries(false, cx);
+                    }
+                    AgentPanelEvent::ThreadFocused => {
+                        if is_active_panel {
+                            let new_focused = agent_panel
+                                .read(cx)
+                                .active_conversation()
+                                .and_then(|cv| cv.read(cx).parent_id(cx));
+                            if new_focused.is_some() && new_focused != this.focused_thread {
+                                this.focused_thread = new_focused;
+                                this.update_entries(false, cx);
+                            }
+                        }
+                    }
+                    AgentPanelEvent::BackgroundThreadChanged => {
                         this.update_entries(false, cx);
                     }
-                }
-                AgentPanelEvent::BackgroundThreadChanged => {
-                    this.update_entries(false, cx);
                 }
             },
         )
         .detach();
     }
 
+    fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
+        self._draft_observation = self
+            .multi_workspace
+            .upgrade()
+            .and_then(|mw| {
+                let ws = mw.read(cx).workspace();
+                ws.read(cx).panel::<AgentPanel>(cx)
+            })
+            .and_then(|panel| {
+                let cv = panel.read(cx).active_conversation()?;
+                let tv = cv.read(cx).active_thread()?;
+                Some(tv.read(cx).message_editor.clone())
+            })
+            .map(|editor| {
+                cx.observe(&editor, |_this, _editor, cx| {
+                    cx.notify();
+                })
+            });
+    }
+
+    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
+        let mw = self.multi_workspace.upgrade()?;
+        let workspace = mw.read(cx).workspace();
+        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+        let conversation_view = panel.read(cx).active_conversation()?;
+        let thread_view = conversation_view.read(cx).active_thread()?;
+        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
+        let cleaned = Self::clean_mention_links(&raw);
+        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
+        if text.is_empty() {
+            None
+        } else {
+            const MAX_CHARS: usize = 250;
+            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
+                text.truncate(truncate_at);
+            }
+            Some(text.into())
+        }
+    }
+
+    fn clean_mention_links(input: &str) -> String {
+        let mut result = String::with_capacity(input.len());
+        let mut remaining = input;
+
+        while let Some(start) = remaining.find("[@") {
+            result.push_str(&remaining[..start]);
+            let after_bracket = &remaining[start + 1..]; // skip '['
+            if let Some(close_bracket) = after_bracket.find("](") {
+                let mention = &after_bracket[..close_bracket]; // "@something"
+                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
+                if let Some(close_paren) = after_link_start.find(')') {
+                    result.push_str(mention);
+                    remaining = &after_link_start[close_paren + 1..];
+                    continue;
+                }
+            }
+            // Couldn't parse full link syntax β€” emit the literal "[@" and move on.
+            result.push_str("[@");
+            remaining = &remaining[start + 2..];
+        }
+        result.push_str(remaining);
+        result
+    }
+
     fn all_thread_infos_for_workspace(
         workspace: &Entity<Workspace>,
         cx: &App,
@@ -486,6 +616,19 @@ impl Sidebar {
 
         let previous = mem::take(&mut self.contents);
 
+        // Collect the session IDs that were visible before this rebuild so we
+        // can distinguish a thread that was deleted/removed (was in the list,
+        // now gone) from a brand-new thread that hasn't been saved to the
+        // metadata store yet (never was in the list).
+        let previous_session_ids: HashSet<acp::SessionId> = previous
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                ListEntry::Thread(t) => Some(t.session_info.session_id.clone()),
+                _ => None,
+            })
+            .collect();
+
         let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
             .entries
             .iter()
@@ -747,8 +890,6 @@ impl Sidebar {
             }
 
             if !query.is_empty() {
-                let has_threads = !threads.is_empty();
-
                 let workspace_highlight_positions =
                     fuzzy_match_positions(&query, &label).unwrap_or_default();
                 let workspace_matched = !workspace_highlight_positions.is_empty();
@@ -782,21 +923,11 @@ impl Sidebar {
                     continue;
                 }
 
-                if active_entry_index.is_none()
-                    && self.focused_thread.is_none()
-                    && active_workspace
-                        .as_ref()
-                        .is_some_and(|active| active == workspace)
-                {
-                    active_entry_index = Some(entries.len());
-                }
-
                 entries.push(ListEntry::ProjectHeader {
                     path_list: path_list.clone(),
                     label,
                     workspace: workspace.clone(),
                     highlight_positions: workspace_highlight_positions,
-                    has_threads,
                 });
 
                 // Track session IDs and compute active_entry_index as we add
@@ -813,30 +944,22 @@ impl Sidebar {
                     entries.push(thread.into());
                 }
             } else {
-                let has_threads = !threads.is_empty();
-
-                // Check if this header is the active entry before pushing it.
-                if active_entry_index.is_none()
-                    && self.focused_thread.is_none()
-                    && active_workspace
-                        .as_ref()
-                        .is_some_and(|active| active == workspace)
-                {
-                    active_entry_index = Some(entries.len());
-                }
-
                 entries.push(ListEntry::ProjectHeader {
                     path_list: path_list.clone(),
                     label,
                     workspace: workspace.clone(),
                     highlight_positions: Vec::new(),
-                    has_threads,
                 });
 
                 if is_collapsed {
                     continue;
                 }
 
+                entries.push(ListEntry::NewThread {
+                    path_list: path_list.clone(),
+                    workspace: workspace.clone(),
+                });
+
                 let total = threads.len();
 
                 let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
@@ -866,13 +989,6 @@ impl Sidebar {
                         is_fully_expanded,
                     });
                 }
-
-                if total == 0 {
-                    entries.push(ListEntry::NewThread {
-                        path_list: path_list.clone(),
-                        workspace: workspace.clone(),
-                    });
-                }
             }
         }
 
@@ -886,6 +1002,22 @@ impl Sidebar {
             .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
             .collect();
 
+        // If focused_thread points to a thread that was previously in the
+        // list but is now gone (deleted, or its workspace was removed), clear
+        // it. We don't try to redirect to a thread in a different project
+        // group β€” the delete_thread method already handles within-group
+        // neighbor selection. If it was never in the list it's a brand-new
+        // thread that hasn't been saved to the metadata store yet β€” leave
+        // things alone and wait for the next rebuild.
+        let focused_thread_was_known = self
+            .focused_thread
+            .as_ref()
+            .is_some_and(|id| previous_session_ids.contains(id));
+
+        if focused_thread_was_known && active_entry_index.is_none() {
+            self.focused_thread = None;
+        }
+
         self.active_entry_index = active_entry_index;
         self.contents = SidebarContents {
             entries,
@@ -969,7 +1101,6 @@ impl Sidebar {
                 label,
                 workspace,
                 highlight_positions,
-                has_threads,
             } => self.render_project_header(
                 ix,
                 false,
@@ -977,7 +1108,6 @@ impl Sidebar {
                 label,
                 workspace,
                 highlight_positions,
-                *has_threads,
                 is_selected,
                 cx,
             ),
@@ -1004,7 +1134,7 @@ impl Sidebar {
             v_flex()
                 .w_full()
                 .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
+                .border_color(cx.theme().colors().border.opacity(0.5))
                 .child(rendered)
                 .into_any_element()
         } else {
@@ -1020,14 +1150,12 @@ impl Sidebar {
         label: &SharedString,
         workspace: &Entity<Workspace>,
         highlight_positions: &[usize],
-        has_threads: bool,
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let id_prefix = if is_sticky { "sticky-" } else { "" };
         let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
         let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
-        let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
 
         let is_collapsed = self.collapsed_groups.contains(path_list);
         let disclosure_icon = if is_collapsed {
@@ -1035,7 +1163,6 @@ impl Sidebar {
         } else {
             IconName::ChevronDown
         };
-        let workspace_for_new_thread = workspace.clone();
         let workspace_for_remove = workspace.clone();
 
         let path_list_for_toggle = path_list.clone();
@@ -1046,10 +1173,6 @@ impl Sidebar {
         let workspace_count = multi_workspace
             .as_ref()
             .map_or(0, |mw| mw.read(cx).workspaces().len());
-        let is_active_workspace = self.focused_thread.is_none()
-            && multi_workspace
-                .as_ref()
-                .is_some_and(|mw| mw.read(cx).workspace() == workspace);
 
         let label = if highlight_positions.is_empty() {
             Label::new(label.clone())
@@ -1065,7 +1188,6 @@ impl Sidebar {
 
         ListItem::new(id)
             .group_name(group_name)
-            .toggle_state(is_active_workspace)
             .focused(is_selected)
             .child(
                 h_flex()
@@ -1123,18 +1245,6 @@ impl Sidebar {
                                 }
                             })),
                         )
-                    })
-                    .when(has_threads, |this| {
-                        this.child(
-                            IconButton::new(ib_id, IconName::NewThread)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .tooltip(Tooltip::text("New Thread"))
-                                .on_click(cx.listener(move |this, _, window, cx| {
-                                    this.selection = None;
-                                    this.create_new_thread(&workspace_for_new_thread, window, cx);
-                                })),
-                        )
                     }),
             )
             .on_click(cx.listener(move |this, _, window, cx| {
@@ -1175,7 +1285,6 @@ impl Sidebar {
             label,
             workspace,
             highlight_positions,
-            has_threads,
         } = self.contents.entries.get(header_idx)?
         else {
             return None;
@@ -1192,7 +1301,6 @@ impl Sidebar {
             &label,
             &workspace,
             &highlight_positions,
-            *has_threads,
             is_selected,
             cx,
         );
@@ -1211,15 +1319,21 @@ impl Sidebar {
             })
             .unwrap_or(px(0.));
 
+        let color = cx.theme().colors();
+        let background = color
+            .title_bar_background
+            .blend(color.panel_background.opacity(0.8));
+
         let element = v_flex()
             .absolute()
             .top(top_offset)
             .left_0()
             .w_full()
-            .bg(cx.theme().colors().surface_background)
+            .bg(background)
             .border_b_1()
-            .border_color(cx.theme().colors().border_variant)
+            .border_color(color.border.opacity(0.5))
             .child(header_element)
+            .shadow_xs()
             .into_any_element();
 
         Some(element)
@@ -1314,44 +1428,6 @@ impl Sidebar {
         });
     }
 
-    fn close_all_projects(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
-            return;
-        };
-
-        let workspace_count = multi_workspace.read(cx).workspaces().len();
-        let active_index = multi_workspace.read(cx).active_workspace_index();
-
-        // Remove all workspaces except the active one, iterating in reverse
-        // so that indices of not-yet-visited workspaces remain valid.
-        for index in (0..workspace_count).rev() {
-            if index != active_index {
-                multi_workspace.update(cx, |multi_workspace, cx| {
-                    multi_workspace.remove_workspace(index, window, cx);
-                });
-            }
-        }
-
-        // Remove all worktrees from the remaining workspace so it becomes empty.
-        let workspace = multi_workspace.read(cx).workspace().clone();
-        let worktree_ids: Vec<_> = workspace
-            .read(cx)
-            .project()
-            .read(cx)
-            .visible_worktrees(cx)
-            .map(|worktree| worktree.read(cx).id())
-            .collect();
-
-        workspace.update(cx, |workspace, cx| {
-            let project = workspace.project().clone();
-            project.update(cx, |project, cx| {
-                for worktree_id in worktree_ids {
-                    project.remove_worktree(worktree_id, cx);
-                }
-            });
-        });
-    }
-
     fn toggle_collapse(
         &mut self,
         path_list: &PathList,
@@ -1664,7 +1740,116 @@ impl Sidebar {
         }
     }
 
-    fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+    fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+        let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+            return;
+        };
+
+        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+        for workspace in workspaces {
+            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                let cancelled =
+                    agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
+                if cancelled {
+                    return;
+                }
+            }
+        }
+    }
+
+    fn delete_thread(
+        &mut self,
+        session_id: &acp::SessionId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        // If we're deleting the currently focused thread, move focus to the
+        // nearest thread within the same project group. We never cross group
+        // boundaries β€” if the group has no other threads, clear focus and open
+        // a blank new thread in the panel instead.
+        if self.focused_thread.as_ref() == Some(session_id) {
+            let current_pos = self.contents.entries.iter().position(|entry| {
+                matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
+            });
+
+            // Find the workspace that owns this thread's project group by
+            // walking backwards to the nearest ProjectHeader. We must use
+            // *this* workspace (not the active workspace) because the user
+            // might be deleting a thread in a non-active group.
+            let group_workspace = current_pos.and_then(|pos| {
+                self.contents.entries[..pos]
+                    .iter()
+                    .rev()
+                    .find_map(|e| match e {
+                        ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
+                        _ => None,
+                    })
+            });
+
+            let next_thread = current_pos.and_then(|pos| {
+                let group_start = self.contents.entries[..pos]
+                    .iter()
+                    .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+                    .map_or(0, |i| i + 1);
+                let group_end = self.contents.entries[pos + 1..]
+                    .iter()
+                    .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+                    .map_or(self.contents.entries.len(), |i| pos + 1 + i);
+
+                let above = self.contents.entries[group_start..pos]
+                    .iter()
+                    .rev()
+                    .find_map(|entry| {
+                        if let ListEntry::Thread(t) = entry {
+                            Some(t)
+                        } else {
+                            None
+                        }
+                    });
+
+                above.or_else(|| {
+                    self.contents.entries[pos + 1..group_end]
+                        .iter()
+                        .find_map(|entry| {
+                            if let ListEntry::Thread(t) = entry {
+                                Some(t)
+                            } else {
+                                None
+                            }
+                        })
+                })
+            });
+
+            if let Some(next) = next_thread {
+                self.focused_thread = Some(next.session_info.session_id.clone());
+
+                if let Some(workspace) = &group_workspace {
+                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                        agent_panel.update(cx, |panel, cx| {
+                            panel.load_agent_thread(
+                                next.agent.clone(),
+                                next.session_info.session_id.clone(),
+                                next.session_info.work_dirs.clone(),
+                                next.session_info.title.clone(),
+                                true,
+                                window,
+                                cx,
+                            );
+                        });
+                    }
+                }
+            } else {
+                self.focused_thread = None;
+                if let Some(workspace) = &group_workspace {
+                    if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                        agent_panel.update(cx, |panel, cx| {
+                            panel.new_thread(&NewThread, window, cx);
+                        });
+                    }
+                }
+            }
+        }
+
         let Some(thread_store) = ThreadStore::try_global(cx) else {
             return;
         };
@@ -1682,7 +1867,7 @@ impl Sidebar {
     fn remove_selected_thread(
         &mut self,
         _: &RemoveSelectedThread,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let Some(ix) = self.selection else {
@@ -1695,7 +1880,7 @@ impl Sidebar {
             return;
         }
         let session_id = thread.session_info.session_id.clone();
-        self.delete_thread(&session_id, cx);
+        self.delete_thread(&session_id, window, cx);
     }
 
     fn render_thread(
@@ -1719,6 +1904,10 @@ impl Sidebar {
 
         let is_hovered = self.hovered_thread_index == Some(ix);
         let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id);
+        let is_running = matches!(
+            thread.status,
+            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
+        );
         let can_delete = thread.agent == Agent::NativeAgent;
         let session_id_for_delete = thread.session_info.session_id.clone();
         let focus_handle = self.focus_handle.clone();
@@ -1781,7 +1970,22 @@ impl Sidebar {
                 }
                 cx.notify();
             }))
-            .when(is_hovered && can_delete, |this| {
+            .when(is_hovered && is_running, |this| {
+                this.action_slot(
+                    IconButton::new("stop-thread", IconName::Stop)
+                        .icon_size(IconSize::Small)
+                        .icon_color(Color::Error)
+                        .style(ButtonStyle::Tinted(TintColor::Error))
+                        .tooltip(Tooltip::text("Stop Generation"))
+                        .on_click({
+                            let session_id = session_id_for_delete.clone();
+                            cx.listener(move |this, _, _window, cx| {
+                                this.stop_thread(&session_id, cx);
+                            })
+                        }),
+                )
+            })
+            .when(is_hovered && can_delete && !is_running, |this| {
                 this.action_slot(
                     IconButton::new("delete-thread", IconName::Trash)
                         .icon_size(IconSize::Small)
@@ -1799,9 +2003,8 @@ impl Sidebar {
                         })
                         .on_click({
                             let session_id = session_id_for_delete.clone();
-                            cx.listener(move |this, _, _window, cx| {
-                                this.delete_thread(&session_id, cx);
-                                cx.stop_propagation();
+                            cx.listener(move |this, _, window, cx| {
+                                this.delete_thread(&session_id, window, cx);
                             })
                         }),
                 )
@@ -1839,6 +2042,44 @@ impl Sidebar {
         self.filter_editor.clone()
     }
 
+    fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let workspace = self
+            .multi_workspace
+            .upgrade()
+            .map(|mw| mw.read(cx).workspace().downgrade());
+
+        let focus_handle = workspace
+            .as_ref()
+            .and_then(|ws| ws.upgrade())
+            .map(|w| w.read(cx).focus_handle(cx))
+            .unwrap_or_else(|| cx.focus_handle());
+
+        let popover_handle = self.recent_projects_popover_handle.clone();
+
+        PopoverMenu::new("sidebar-recent-projects-menu")
+            .with_handle(popover_handle)
+            .menu(move |window, cx| {
+                workspace.as_ref().map(|ws| {
+                    RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
+                })
+            })
+            .trigger_with_tooltip(
+                IconButton::new("open-project", IconName::OpenFolder)
+                    .icon_size(IconSize::Small)
+                    .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
+                |_window, cx| {
+                    Tooltip::for_action(
+                        "Recent Projects",
+                        &OpenRecent {
+                            create_new_window: false,
+                        },
+                        cx,
+                    )
+                },
+            )
+            .anchor(gpui::Corner::TopLeft)
+    }
+
     fn render_view_more(
         &self,
         ix: usize,
@@ -1851,27 +2092,24 @@ impl Sidebar {
         let path_list = path_list.clone();
         let id = SharedString::from(format!("view-more-{}", ix));
 
-        let (icon, label) = if is_fully_expanded {
-            (IconName::ListCollapse, "Collapse")
+        let icon = if is_fully_expanded {
+            IconName::ListCollapse
         } else {
-            (IconName::Plus, "View More")
+            IconName::Plus
         };
 
-        ListItem::new(id)
+        let label: SharedString = if is_fully_expanded {
+            "Collapse".into()
+        } else if remaining_count > 0 {
+            format!("View More ({})", remaining_count).into()
+        } else {
+            "View More".into()
+        };
+
+        ThreadItem::new(id, label)
+            .icon(icon)
             .focused(is_selected)
-            .child(
-                h_flex()
-                    .py_1()
-                    .gap_1p5()
-                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
-                    .child(Label::new(label).color(Color::Muted))
-                    .when(!is_fully_expanded, |this| {
-                        this.child(
-                            Label::new(format!("({})", remaining_count))
-                                .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
-                        )
-                    }),
-            )
+            .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85)))
             .on_click(cx.listener(move |this, _, _window, cx| {
                 this.selection = None;
                 if is_fully_expanded {
@@ -1885,6 +2123,39 @@ impl Sidebar {
             .into_any_element()
     }
 
+    fn new_thread_in_group(
+        &mut self,
+        _: &NewThreadInGroup,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        // If there is a keyboard selection, walk backwards through
+        // `project_header_indices` to find the header that owns the selected
+        // row. Otherwise fall back to the active workspace.
+        let workspace = if let Some(selected_ix) = self.selection {
+            self.contents
+                .project_header_indices
+                .iter()
+                .rev()
+                .find(|&&header_ix| header_ix <= selected_ix)
+                .and_then(|&header_ix| match &self.contents.entries[header_ix] {
+                    ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
+                    _ => None,
+                })
+        } else {
+            // Use the currently active workspace.
+            self.multi_workspace
+                .upgrade()
+                .map(|mw| mw.read(cx).workspace().clone())
+        };
+
+        let Some(workspace) = workspace else {
+            return;
+        };
+
+        self.create_new_thread(&workspace, window, cx);
+    }
+
     fn create_new_thread(
         &mut self,
         workspace: &Entity<Workspace>,
@@ -1895,6 +2166,12 @@ impl Sidebar {
             return;
         };
 
+        // Clear focused_thread immediately so no existing thread stays
+        // highlighted while the new blank thread is being shown. Without this,
+        // if the target workspace is already active (so ActiveWorkspaceChanged
+        // never fires), the previous thread's highlight would linger.
+        self.focused_thread = None;
+
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), cx);
         });
@@ -1917,126 +2194,126 @@ impl Sidebar {
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
+        let is_active = self.active_entry_index.is_none()
+            && self
+                .multi_workspace
+                .upgrade()
+                .map_or(false, |mw| mw.read(cx).workspace() == workspace);
+
+        let label: SharedString = if is_active {
+            self.active_draft_text(cx)
+                .unwrap_or_else(|| "New Thread".into())
+        } else {
+            "New Thread".into()
+        };
+
         let workspace = workspace.clone();
+        let id = SharedString::from(format!("new-thread-btn-{}", ix));
 
-        div()
-            .w_full()
-            .p_2()
-            .pt_1p5()
-            .child(
-                Button::new(
-                    SharedString::from(format!("new-thread-btn-{}", ix)),
-                    "New Thread",
-                )
-                .full_width()
-                .style(ButtonStyle::Outlined)
-                .start_icon(
-                    Icon::new(IconName::Plus)
-                        .size(IconSize::Small)
-                        .color(Color::Muted),
-                )
-                .toggle_state(is_selected)
-                .on_click(cx.listener(move |this, _, window, cx| {
-                    this.selection = None;
-                    this.create_new_thread(&workspace, window, cx);
-                })),
-            )
+        ThreadItem::new(id, label)
+            .icon(IconName::Plus)
+            .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);
+            }))
             .into_any_element()
     }
 
-    fn render_thread_list_header(
-        &self,
-        window: &Window,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
+    fn render_sidebar_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
         let has_query = self.has_filter_query(cx);
-        let needs_traffic_light_padding = cfg!(target_os = "macos") && !window.is_fullscreen();
-        let has_open_projects = self
-            .multi_workspace
-            .upgrade()
-            .map(|mw| {
-                let mw = mw.read(cx);
-                mw.workspaces().len() > 1
-                    || mw
-                        .workspace()
-                        .read(cx)
-                        .project()
-                        .read(cx)
-                        .visible_worktrees(cx)
-                        .next()
-                        .is_some()
-            })
-            .unwrap_or(false);
+        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+        let header_height = platform_title_bar_height(window);
 
         v_flex()
-            .flex_none()
             .child(
                 h_flex()
-                    .h(Tab::container_height(cx) - px(1.))
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-                    .when(needs_traffic_light_padding, |this| {
+                    .h(header_height)
+                    .mt_px()
+                    .pb_px()
+                    .when(traffic_lights, |this| {
                         this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
                     })
-                    .child(self.render_sidebar_toggle_button(cx)),
-            )
-            .child(
-                h_flex()
-                    .h(Tab::container_height(cx))
-                    .gap_1p5()
-                    .px_1p5()
+                    .pr_1p5()
                     .border_b_1()
                     .border_color(cx.theme().colors().border)
-                    .child(self.render_filter_input())
+                    .justify_between()
+                    .child(self.render_sidebar_toggle_button(cx))
                     .child(
                         h_flex()
                             .gap_0p5()
-                            .when(has_query, |this| {
-                                this.child(
-                                    IconButton::new("clear_filter", IconName::Close)
-                                        .shape(IconButtonShape::Square)
-                                        .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(has_open_projects, |this| {
-                                this.child(
-                                    IconButton::new("close-all-projects", IconName::Exit)
-                                        .icon_size(IconSize::Small)
-                                        .tooltip(Tooltip::text("Close All Projects"))
-                                        .on_click(cx.listener(|this, _, window, cx| {
-                                            this.close_all_projects(window, cx);
-                                        })),
-                                )
-                            })
                             .child(
                                 IconButton::new("archive", IconName::Archive)
                                     .icon_size(IconSize::Small)
-                                    .tooltip(Tooltip::text("Archive"))
+                                    .tooltip(Tooltip::text("View Archived Threads"))
                                     .on_click(cx.listener(|this, _, window, cx| {
                                         this.show_archive(window, cx);
                                     })),
-                            ),
+                            )
+                            .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())
+                    .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 {
         let icon = IconName::ThreadsSidebarLeftOpen;
 
-        h_flex().h_full().child(
-            IconButton::new("sidebar-close-toggle", icon)
-                .icon_size(IconSize::Small)
-                .tooltip(move |_, cx| {
-                    Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
-                })
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-                }),
-        )
+        IconButton::new("sidebar-close-toggle", icon)
+            .icon_size(IconSize::Small)
+            .tooltip(Tooltip::element(move |_window, cx| {
+                v_flex()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(Label::new("Toggle Sidebar"))
+                            .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
+                    )
+                    .child(
+                        h_flex()
+                            .pt_1()
+                            .gap_2()
+                            .border_t_1()
+                            .border_color(cx.theme().colors().border_variant)
+                            .justify_between()
+                            .child(Label::new("Focus Sidebar"))
+                            .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
+                    )
+                    .into_any_element()
+            }))
+            .on_click(|_, window, cx| {
+                window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+            })
     }
 }
 

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

@@ -41,8 +41,8 @@ use std::sync::Arc;
 use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
 use ui::{
-    Avatar, ButtonLike, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle,
-    TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
+    Avatar, ButtonLike, ContextMenu, Divider, IconWithIndicator, Indicator, PopoverMenu,
+    PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
 };
 use update_version::UpdateVersion;
 use util::ResultExt;
@@ -169,6 +169,7 @@ impl Render for TitleBar {
 
         children.push(
             h_flex()
+                .h_full()
                 .gap_0p5()
                 .map(|title_bar| {
                     let mut render_project_items = title_bar_settings.show_branch_name
@@ -705,23 +706,29 @@ impl TitleBar {
         let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
 
         Some(
-            IconButton::new(
-                "toggle-workspace-sidebar",
-                IconName::ThreadsSidebarLeftClosed,
-            )
-            .icon_size(IconSize::Small)
-            .when(has_notifications, |button| {
-                button
-                    .indicator(Indicator::dot().color(Color::Accent))
-                    .indicator_border_color(Some(cx.theme().colors().title_bar_background))
-            })
-            .tooltip(move |_, cx| {
-                Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
-            })
-            .on_click(|_, window, cx| {
-                window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-            })
-            .into_any_element(),
+            h_flex()
+                .h_full()
+                .gap_0p5()
+                .child(
+                    IconButton::new(
+                        "toggle-workspace-sidebar",
+                        IconName::ThreadsSidebarLeftClosed,
+                    )
+                    .icon_size(IconSize::Small)
+                    .when(has_notifications, |button| {
+                        button
+                            .indicator(Indicator::dot().color(Color::Accent))
+                            .indicator_border_color(Some(cx.theme().colors().title_bar_background))
+                    })
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+                    })
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                    }),
+                )
+                .child(Divider::vertical().color(ui::DividerColor::Border))
+                .into_any_element(),
         )
     }
 
@@ -741,6 +748,14 @@ impl TitleBar {
             "Open Recent Project".to_string()
         };
 
+        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
+
+        if is_sidebar_open {
+            return self
+                .render_project_name_with_sidebar_popover(display_name, is_project_selected, cx)
+                .into_any_element();
+        }
+
         let focus_handle = workspace
             .upgrade()
             .map(|w| w.read(cx).focus_handle(cx))
@@ -782,6 +797,53 @@ 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(
+        &self,
+        display_name: String,
+        is_project_selected: bool,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let multi_workspace = self.multi_workspace.clone();
+
+        let is_popover_deployed = 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),
+                )
+            })
+            .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,
+                    },
+                    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);
+                    });
+                }
+            })
+    }
+
     pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
         let effective_worktree = self.effective_active_worktree(cx)?;
         let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;

crates/ui/src/components/ai.rs πŸ”—

@@ -1,7 +1,5 @@
 mod configured_api_card;
 mod thread_item;
-mod thread_sidebar_toggle;
 
 pub use configured_api_card::*;
 pub use thread_item::*;
-pub use thread_sidebar_toggle::*;

crates/ui/src/components/ai/thread_item.rs πŸ”—

@@ -3,7 +3,10 @@ use crate::{
     IconDecorationKind, prelude::*,
 };
 
-use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between};
+use gpui::{
+    Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
+    pulsating_between,
+};
 use std::time::Duration;
 
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -36,6 +39,7 @@ pub struct ThreadItem {
     worktree_highlight_positions: Vec<usize>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+    title_label_color: Option<Color>,
     action_slot: Option<AnyElement>,
     tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 }
@@ -62,6 +66,7 @@ impl ThreadItem {
             worktree_highlight_positions: Vec::new(),
             on_click: None,
             on_hover: Box::new(|_, _, _| {}),
+            title_label_color: None,
             action_slot: None,
             tooltip: None,
         }
@@ -155,6 +160,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn title_label_color(mut self, color: Color) -> Self {
+        self.title_label_color = Some(color);
+        self
+    }
+
     pub fn action_slot(mut self, element: impl IntoElement) -> Self {
         self.action_slot = Some(element.into_any_element());
         self
@@ -230,7 +240,7 @@ impl RenderOnce for ThreadItem {
         let title = self.title;
         let highlight_positions = self.highlight_positions;
         let title_label = if self.generating_title {
-            Label::new("New Thread…")
+            Label::new(title)
                 .color(Color::Muted)
                 .with_animation(
                     "generating-title",
@@ -241,15 +251,31 @@ impl RenderOnce for ThreadItem {
                 )
                 .into_any_element()
         } else if highlight_positions.is_empty() {
-            Label::new(title).into_any_element()
+            let label = Label::new(title);
+            let label = if let Some(color) = self.title_label_color {
+                label.color(color)
+            } else {
+                label
+            };
+            label.into_any_element()
         } else {
-            HighlightedLabel::new(title, highlight_positions).into_any_element()
+            let label = HighlightedLabel::new(title, highlight_positions);
+            let label = if let Some(color) = self.title_label_color {
+                label.color(color)
+            } else {
+                label
+            };
+            label.into_any_element()
         };
 
+        let b_bg = color
+            .title_bar_background
+            .blend(color.panel_background.opacity(0.8));
+
         let base_bg = if self.selected {
             color.element_active
         } else {
-            color.panel_background
+            b_bg
         };
 
         let gradient_overlay =
@@ -314,7 +340,15 @@ impl RenderOnce for ThreadItem {
                             .gradient_stop(0.75)
                             .group_name("thread-item");
 
-                            this.child(h_flex().relative().child(overlay).child(slot))
+                            this.child(
+                                h_flex()
+                                    .relative()
+                                    .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                                        cx.stop_propagation()
+                                    })
+                                    .child(overlay)
+                                    .child(slot),
+                            )
                         })
                     }),
             )

crates/ui/src/components/ai/thread_sidebar_toggle.rs πŸ”—

@@ -1,177 +0,0 @@
-use gpui::{AnyView, ClickEvent};
-use ui_macros::RegisterComponent;
-
-use crate::prelude::*;
-use crate::{IconButton, IconName, Tooltip};
-
-#[derive(IntoElement, RegisterComponent)]
-pub struct ThreadSidebarToggle {
-    sidebar_selected: bool,
-    thread_selected: bool,
-    flipped: bool,
-    sidebar_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
-    thread_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
-    on_sidebar_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
-    on_thread_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
-}
-
-impl ThreadSidebarToggle {
-    pub fn new() -> Self {
-        Self {
-            sidebar_selected: false,
-            thread_selected: false,
-            flipped: false,
-            sidebar_tooltip: None,
-            thread_tooltip: None,
-            on_sidebar_click: None,
-            on_thread_click: None,
-        }
-    }
-
-    pub fn sidebar_selected(mut self, selected: bool) -> Self {
-        self.sidebar_selected = selected;
-        self
-    }
-
-    pub fn thread_selected(mut self, selected: bool) -> Self {
-        self.thread_selected = selected;
-        self
-    }
-
-    pub fn flipped(mut self, flipped: bool) -> Self {
-        self.flipped = flipped;
-        self
-    }
-
-    pub fn sidebar_tooltip(
-        mut self,
-        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
-    ) -> Self {
-        self.sidebar_tooltip = Some(Box::new(tooltip));
-        self
-    }
-
-    pub fn thread_tooltip(
-        mut self,
-        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
-    ) -> Self {
-        self.thread_tooltip = Some(Box::new(tooltip));
-        self
-    }
-
-    pub fn on_sidebar_click(
-        mut self,
-        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        self.on_sidebar_click = Some(Box::new(handler));
-        self
-    }
-
-    pub fn on_thread_click(
-        mut self,
-        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        self.on_thread_click = Some(Box::new(handler));
-        self
-    }
-}
-
-impl RenderOnce for ThreadSidebarToggle {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let sidebar_icon = match (self.sidebar_selected, self.flipped) {
-            (true, false) => IconName::ThreadsSidebarLeftOpen,
-            (false, false) => IconName::ThreadsSidebarLeftClosed,
-            (true, true) => IconName::ThreadsSidebarRightOpen,
-            (false, true) => IconName::ThreadsSidebarRightClosed,
-        };
-
-        h_flex()
-            .min_w_0()
-            .rounded_sm()
-            .gap_px()
-            .border_1()
-            .border_color(cx.theme().colors().border)
-            .when(self.flipped, |this| this.flex_row_reverse())
-            .child(
-                IconButton::new("sidebar-toggle", sidebar_icon)
-                    .icon_size(IconSize::Small)
-                    .toggle_state(self.sidebar_selected)
-                    .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip))
-                    .when_some(self.on_sidebar_click, |this, handler| {
-                        this.on_click(handler)
-                    }),
-            )
-            .child(div().h_4().w_px().bg(cx.theme().colors().border))
-            .child(
-                IconButton::new("thread-toggle", IconName::Thread)
-                    .icon_size(IconSize::Small)
-                    .toggle_state(self.thread_selected)
-                    .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip))
-                    .when_some(self.on_thread_click, |this, handler| this.on_click(handler)),
-            )
-    }
-}
-
-impl Component for ThreadSidebarToggle {
-    fn scope() -> ComponentScope {
-        ComponentScope::Agent
-    }
-
-    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
-        let container = || div().p_2().bg(cx.theme().colors().status_bar_background);
-
-        let examples = vec![
-            single_example(
-                "Both Unselected",
-                container()
-                    .child(ThreadSidebarToggle::new())
-                    .into_any_element(),
-            ),
-            single_example(
-                "Sidebar Selected",
-                container()
-                    .child(ThreadSidebarToggle::new().sidebar_selected(true))
-                    .into_any_element(),
-            ),
-            single_example(
-                "Thread Selected",
-                container()
-                    .child(ThreadSidebarToggle::new().thread_selected(true))
-                    .into_any_element(),
-            ),
-            single_example(
-                "Both Selected",
-                container()
-                    .child(
-                        ThreadSidebarToggle::new()
-                            .sidebar_selected(true)
-                            .thread_selected(true),
-                    )
-                    .into_any_element(),
-            ),
-            single_example(
-                "Flipped",
-                container()
-                    .child(
-                        ThreadSidebarToggle::new()
-                            .sidebar_selected(true)
-                            .thread_selected(true)
-                            .flipped(true),
-                    )
-                    .into_any_element(),
-            ),
-            single_example(
-                "With Tooltips",
-                container()
-                    .child(
-                        ThreadSidebarToggle::new()
-                            .sidebar_tooltip(Tooltip::text("Toggle Sidebar"))
-                            .thread_tooltip(Tooltip::text("Toggle Thread")),
-                    )
-                    .into_any_element(),
-            ),
-        ];
-
-        Some(example_group(examples).into_any_element())
-    }
-}

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

@@ -45,6 +45,8 @@ 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;
 }
 
 pub trait SidebarHandle: 'static + Send + Sync {
@@ -55,6 +57,8 @@ 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;
 }
 
 #[derive(Clone)]
@@ -95,6 +99,16 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
     fn entity_id(&self) -> EntityId {
         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()
+    }
 }
 
 pub struct MultiWorkspace {
@@ -167,6 +181,18 @@ 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 multi_workspace_enabled(&self, cx: &App) -> bool {
         cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
     }