From 9f3e3be65f231d050a2618fb2555b4eb0bf1023a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:44:20 -0300 Subject: [PATCH] agent: Improve sidebar design and behavior (#51763) - 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 --- 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 | 1007 ++++++++++++----- crates/title_bar/src/title_bar.rs | 100 +- crates/ui/src/components/ai.rs | 2 - crates/ui/src/components/ai/thread_item.rs | 46 +- .../components/ai/thread_sidebar_toggle.rs | 177 --- crates/workspace/src/multi_workspace.rs | 26 + 13 files changed, 909 insertions(+), 543 deletions(-) delete mode 100644 crates/ui/src/components/ai/thread_sidebar_toggle.rs diff --git a/Cargo.lock b/Cargo.lock index e2747e6853d1bcf047b4e1c7adf58bc4d03a845d..de9ba49222524c23885e36a5f9ea8234c8c589ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15870,6 +15870,7 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", + "recent_projects", "serde_json", "settings", "theme", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ab82c5d3f82262472a58aa35c72b9d478786fb31..03afecb5a7fb5939249f8889020a2c6b482edf09 100644 --- a/assets/keymaps/default-linux.json +++ b/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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ae930523e371a0cc8416da30c3e3384566cb3a4c..47d56d8683c5a349ebee3e652cbf36e2ad124ffe 100644 --- a/assets/keymaps/default-macos.json +++ b/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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index b8e87c4a8687f2fc20ba1f3aadcbf8fc97eef61a..d4c2adbdbde104eb0157ba25906150a9453a4625 100644 --- a/assets/keymaps/default-windows.json +++ b/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", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0fa332532f312e9d1815fdefa03abd8cdb32185f..7d157c6ad6fe44ef13a88d8e54b28b40042aeed8 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -601,8 +601,8 @@ impl From 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) -> 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> { @@ -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({ diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 503dea7286a49ddec10e186c9fdafe765134e078..9b4f9d215f6c572c64a8f548d3bd6955a9ff38ec 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/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) -> impl IntoElement { + fn render_header(&self, window: &Window, cx: &mut Context) -> 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) } } diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 0539450e1444535349a9ee53c195deb1ad830e10..5d637bb49d0922039930caa12611b8a53757fd8d 100644 --- a/crates/sidebar/Cargo.toml +++ b/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 diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 692844a37e96905f0458d19435c58598fd0d7faf..c5494cac874d09a521c351adc1a7c9293019ff8f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, highlight_positions: Vec, - 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, focused_thread: Option, + /// 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, hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, view: SidebarView, archive_view: Option>, + recent_projects_popover_handle: PopoverMenuHandle, _subscriptions: Vec, _update_entries_task: Option>, + _draft_observation: Option, } 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::(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, window: &mut Window, cx: &mut Context, @@ -372,11 +412,19 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(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, window: &mut Window, cx: &mut Context, @@ -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::(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._draft_observation = self + .multi_workspace + .upgrade() + .and_then(|mw| { + let ws = mw.read(cx).workspace(); + ws.read(cx).panel::(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 { + let mw = self.multi_workspace.upgrade()?; + let workspace = mw.read(cx).workspace(); + let panel = workspace.read(cx).panel::(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::>().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, 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 = previous + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::Thread(t) => Some(t.session_info.session_id.clone()), + _ => None, + }) + .collect(); + let old_statuses: HashMap = 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, highlight_positions: &[usize], - has_threads: bool, is_selected: bool, cx: &mut Context, ) -> 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) { - 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) { + fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + 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::(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, + ) { + // 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::(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::(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, ) { 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) -> 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, + ) { + // 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, @@ -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, ) -> 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, - ) -> impl IntoElement { + fn render_sidebar_header(&self, window: &Window, cx: &mut Context) -> 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) -> 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); + }) } } @@ -2120,6 +2397,14 @@ impl WorkspaceSidebar for Sidebar { fn has_notifications(&self, _cx: &App) -> bool { !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() + } } impl Focusable for Sidebar { @@ -2133,6 +2418,11 @@ impl Render for Sidebar { let _titlebar_height = ui::utils::platform_title_bar_height(window); let ui_font = theme::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); + let bg = cx + .theme() + .colors() + .title_bar_background + .blend(cx.theme().colors().panel_background.opacity(0.8)); v_flex() .id("workspace-sidebar") @@ -2149,16 +2439,16 @@ impl Render for Sidebar { .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(Self::new_thread_in_group)) .font(ui_font) .h_full() .w(self.width) - .bg(cx.theme().colors().surface_background) + .bg(bg) .border_r_1() .border_color(cx.theme().colors().border) .map(|this| match self.view { - SidebarView::ThreadList => this - .child(self.render_thread_list_header(window, cx)) - .child( + SidebarView::ThreadList => { + this.child(self.render_sidebar_header(window, cx)).child( v_flex() .relative() .flex_1() @@ -2173,7 +2463,8 @@ impl Render for Sidebar { ) .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), - ), + ) + } SidebarView::Archive => { if let Some(archive_view) = &self.archive_view { this.child(archive_view.clone()) @@ -2410,6 +2701,44 @@ mod tests { }) } + #[test] + fn test_clean_mention_links() { + // Simple mention link + assert_eq!( + Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), + "check @Button.tsx" + ); + + // Multiple mention links + assert_eq!( + Sidebar::clean_mention_links( + "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" + ), + "look at @foo.rs and @bar.rs" + ); + + // No mention links — passthrough + assert_eq!( + Sidebar::clean_mention_links("plain text with no mentions"), + "plain text with no mentions" + ); + + // Incomplete link syntax — preserved as-is + assert_eq!( + Sidebar::clean_mention_links("broken [@mention without closing"), + "broken [@mention without closing" + ); + + // Regular markdown link (no @) — not touched + assert_eq!( + Sidebar::clean_mention_links("see [docs](https://example.com)"), + "see [docs](https://example.com)" + ); + + // Empty input + assert_eq!(Sidebar::clean_mention_links(""), ""); + } + #[gpui::test] async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -2458,6 +2787,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", + " [+ New Thread]", " Fix crash in project panel", " Add inline diff view", ] @@ -2489,7 +2819,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] + vec!["v [project-a]", " [+ New Thread]", " Thread A1"] ); // Add a second workspace @@ -2502,6 +2832,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", + " [+ New Thread]", " Thread A1", "v [Empty Workspace]", " [+ New Thread]" @@ -2516,7 +2847,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] + vec!["v [project-a]", " [+ New Thread]", " Thread A1"] ); } @@ -2537,6 +2868,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", + " [+ New Thread]", " Thread 12", " Thread 11", " Thread 10", @@ -2561,22 +2893,22 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Initially shows 5 threads + View More (12 remaining) + // Initially shows NewThread + 5 threads + View More (12 remaining) let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More assert!(entries.iter().any(|e| e.contains("View More (12)"))); // Focus and navigate to View More, then confirm to expand by one batch open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { + for _ in 0..8 { cx.dispatch_action(SelectNext); } cx.dispatch_action(Confirm); cx.run_until_parked(); - // Now shows 10 threads + View More (7 remaining) + // Now shows NewThread + 10 threads + View More (7 remaining) let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert_eq!(entries.len(), 13); // header + NewThread + 10 threads + View More assert!(entries.iter().any(|e| e.contains("View More (7)"))); // Expand again by one batch @@ -2587,9 +2919,9 @@ mod tests { }); cx.run_until_parked(); - // Now shows 15 threads + View More (2 remaining) + // Now shows NewThread + 15 threads + View More (2 remaining) let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert_eq!(entries.len(), 18); // header + NewThread + 15 threads + View More assert!(entries.iter().any(|e| e.contains("View More (2)"))); // Expand one more time - should show all 17 threads with Collapse button @@ -2602,7 +2934,7 @@ mod tests { // All 17 threads shown with Collapse button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert_eq!(entries.len(), 20); // header + NewThread + 17 threads + Collapse assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); @@ -2613,9 +2945,9 @@ mod tests { }); cx.run_until_parked(); - // Back to initial state: 5 threads + View More (12 remaining) + // Back to initial state: NewThread + 5 threads + View More (12 remaining) let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More assert!(entries.iter().any(|e| e.contains("View More (12)"))); } @@ -2634,7 +2966,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec!["v [my-project]", " [+ New Thread]", " Thread 1"] ); // Collapse @@ -2656,7 +2988,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec!["v [my-project]", " [+ New Thread]", " Thread 1"] ); } @@ -2683,7 +3015,6 @@ mod tests { label: "expanded-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), - has_threads: true, }, // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { @@ -2812,7 +3143,6 @@ mod tests { label: "collapsed-project".into(), workspace: workspace.clone(), highlight_positions: Vec::new(), - has_threads: true, }, ]; // Select the Running thread (index 2) @@ -2872,7 +3202,7 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Entries: [header, thread3, thread2, thread1] + // Entries: [header, new_thread, thread3, thread2, thread1] // Focusing the sidebar does not set a selection; select_next/select_previous // handle None gracefully by starting from the first or last entry. open_and_focus_sidebar(&sidebar, cx); @@ -2892,12 +3222,18 @@ mod tests { cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); + // At the end, selection stays on the last entry cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); // Move back up + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); @@ -2928,7 +3264,7 @@ mod tests { // SelectLast jumps to the end cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); // SelectFirst jumps to the beginning cx.dispatch_action(SelectFirst); @@ -2988,6 +3324,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", + " [+ New Thread]", " Thread 1", "v [Empty Workspace]", " [+ New Thread]", @@ -3042,17 +3379,17 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Should show header + 5 threads + "View More (3)" + // Should show header + NewThread + 5 threads + "View More (3)" let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); + assert_eq!(entries.len(), 8); assert!(entries.iter().any(|e| e.contains("View More (3)"))); - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 7) open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { + for _ in 0..8 { cx.dispatch_action(SelectNext); } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(7)); // Confirm on "View More" to expand cx.dispatch_action(Confirm); @@ -3060,7 +3397,7 @@ mod tests { // All 8 threads should now be visible with a "Collapse" button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert_eq!(entries.len(), 11); // header + NewThread + 8 threads + Collapse button assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); } @@ -3079,7 +3416,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] + vec!["v [my-project]", " [+ New Thread]", " Thread 1"] ); // Focus sidebar and manually select the header (index 0). Press left to collapse. @@ -3102,7 +3439,11 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] + vec![ + "v [my-project] <== selected", + " [+ New Thread]", + " Thread 1", + ] ); // Press right again on already-expanded header moves selection down @@ -3126,11 +3467,16 @@ mod tests { open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] + vec![ + "v [my-project]", + " [+ New Thread]", + " Thread 1 <== selected", + ] ); // Pressing left on a child collapses the parent group and selects it @@ -3190,11 +3536,12 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + // Focus sidebar (selection starts at None), navigate down to the thread (index 2) open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); // Collapse the group, which removes the thread from the list cx.dispatch_action(CollapseSelectedEntry); @@ -3294,10 +3641,15 @@ mod tests { cx.run_until_parked(); let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); + entries[2..].sort(); assert_eq!( entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] + vec![ + "v [my-project]", + " [+ New Thread]", + " Hello *", + " Hello * (running)", + ] ); } @@ -3340,6 +3692,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", + " [+ New Thread]", " Hello * (running)", "v [Empty Workspace]", " [+ New Thread]", @@ -3355,6 +3708,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", + " [+ New Thread]", " Hello * (!)", "v [Empty Workspace]", " [+ New Thread]", @@ -3401,6 +3755,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", + " [+ New Thread]", " Fix crash in project panel", " Add inline diff view", " Refactor settings module", @@ -3491,7 +3846,12 @@ mod tests { // Confirm the full list is showing. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] + vec![ + "v [my-project]", + " [+ New Thread]", + " Alpha thread", + " Beta thread", + ] ); // User types a search query to filter down. @@ -3503,13 +3863,16 @@ mod tests { ); // User presses Escape — filter clears, full list is restored. + // The selection index (1) now points at the NewThread entry that was + // re-inserted when the filter was removed. cx.dispatch_action(Cancel); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " Alpha thread <== selected", + " [+ New Thread] <== selected", + " Alpha thread", " Beta thread", ] ); @@ -3565,9 +3928,11 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", + " [+ New Thread]", " Fix bug in sidebar", " Add tests for editor", "v [Empty Workspace]", + " [+ New Thread]", " Refactor sidebar layout", " Fix typo in README", ] @@ -3902,6 +4267,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", + " [+ New Thread]", " Historical Thread", "v [Empty Workspace]", " [+ New Thread]", @@ -3967,17 +4333,22 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] + vec![ + "v [my-project]", + " [+ New Thread]", + " Thread A", + " Thread B", + ] ); // Keyboard confirm preserves selection. sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); + sidebar.selection = Some(2); sidebar.confirm(&Confirm, window, cx); }); assert_eq!( sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) + Some(2) ); // Click handlers clear selection to None so no highlight lingers @@ -4020,7 +4391,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] + vec!["v [my-project]", " [+ New Thread]", " Hello *"] ); // Simulate the agent generating a title. The notification chain is: @@ -4042,7 +4413,11 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] + vec![ + "v [my-project]", + " [+ New Thread]", + " Friendly Greeting with AI *" + ] ); } @@ -4080,18 +4455,14 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); // ── 1. Initial state: no focused thread ────────────────────────────── - // Workspace B is active (just added), so its header is the active entry. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( sidebar.focused_thread, None, "Initially no thread should be focused" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the active workspace header" + assert_eq!( + sidebar.active_entry_index, None, + "No active entry when no thread is focused" ); }); @@ -4195,12 +4566,9 @@ mod tests { sidebar.focused_thread, None, "External workspace switch should clear focused_thread" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header after external switch" + assert_eq!( + sidebar.active_entry_index, None, + "No active entry when no thread is focused" ); }); @@ -4214,11 +4582,14 @@ mod tests { save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; cx.run_until_parked(); + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Opening a thread externally should set focused_thread" + sidebar.focused_thread, None, + "Opening a thread in a non-active panel should not set focused_thread" ); }); @@ -4229,9 +4600,8 @@ mod tests { sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Defocusing the sidebar should not clear focused_thread" + sidebar.focused_thread, None, + "Defocusing the sidebar should not set focused_thread" ); }); @@ -4245,12 +4615,9 @@ mod tests { sidebar.focused_thread, None, "Clicking a workspace header should clear focused_thread" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header" + assert_eq!( + sidebar.active_entry_index, None, + "No active entry when no thread is focused" ); }); @@ -4379,7 +4746,11 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] + vec![ + "v [project]", + " [+ New Thread]", + " Worktree Thread {rosewood}", + ] ); } @@ -4456,8 +4827,10 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [wt-feature-a]", + " [+ New Thread]", " Thread A", "v [wt-feature-b]", + " [+ New Thread]", " Thread B", ] ); @@ -4494,6 +4867,7 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", + " [+ New Thread]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", ] @@ -4514,7 +4888,11 @@ mod tests { // under the main repo. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Thread A {wt-feature-a}",] + vec![ + "v [project]", + " [+ New Thread]", + " Thread A {wt-feature-a}", + ] ); } @@ -4582,7 +4960,11 @@ mod tests { // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], + vec![ + "v [project]", + " [+ New Thread]", + " WT Thread {wt-feature-a}" + ], ); // Only 1 workspace should exist. @@ -4594,7 +4976,7 @@ mod tests { // Focus the sidebar and select the worktree thread. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); // index 0 is header, 1 is the thread + sidebar.selection = Some(2); // index 0 is header, 1 is NewThread, 2 is the thread }); // Confirm to open the worktree thread. @@ -4699,8 +5081,9 @@ mod tests { // The worktree workspace should be absorbed under the main repo. let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 3); + assert_eq!(entries.len(), 4); assert_eq!(entries[0], "v [project]"); + assert_eq!(entries[1], " [+ New Thread]"); assert!(entries.contains(&" Main Thread".to_string())); assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 4e20c607c297c5461582a32f07352b993dcf4aa7..10249c3187a472951604983505aec32a398b92c2 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/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, + ) -> 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) -> Option { let effective_worktree = self.effective_active_worktree(cx)?; let repository = self.get_repository_for_worktree(&effective_worktree, cx)?; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index de6b74afb02e23d5fa87a01ae448d63979815870..a31db264e985b3adbca26b9e8d3fb2bdca306dcb 100644 --- a/crates/ui/src/components/ai.rs +++ b/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::*; diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 6ab137227a4699e38a90b530a5554e6fe66f1ee5..ed9192f5ab9bb9054e201e3661a7df3d742e20c8 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/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, on_click: Option>, on_hover: Box, + title_label_color: Option, action_slot: Option, tooltip: Option 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), + ) }) }), ) diff --git a/crates/ui/src/components/ai/thread_sidebar_toggle.rs b/crates/ui/src/components/ai/thread_sidebar_toggle.rs deleted file mode 100644 index 606d7f1eed6852f677b7167e0b868c1c1e3847c2..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/ai/thread_sidebar_toggle.rs +++ /dev/null @@ -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 AnyView + 'static>>, - thread_tooltip: Option AnyView + 'static>>, - on_sidebar_click: Option>, - on_thread_click: Option>, -} - -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 { - 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()) - } -} diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 7dda99774c3602809d4ca6a9fe6b92cbb0cc69ff..ac152d338ca8b3cb477340b7db7a29fd621027be 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/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, cx: &mut Context); 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 SidebarHandle for Entity { 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::() && !DisableAiSettings::get_global(cx).disable_ai }