From e1268577321e7b48596166701b6f061b255b441a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:22:32 -0300 Subject: [PATCH] sidebar: Add another round of refinements (#52101) - Change the branch button's tooltip to be more accurate given it displays more stuff than only branches - Hide the worktree dropdown menu when in a non-Git repo project - Improve provisioned title truncation - Remove the plus icon from the "view more" item to improve sidebar's overall feel - Remove the always visible "new thread" button but make it visible only when you're in an empty thread state - Add worktree icon in the thread item and tooltip with full path - Space out the worktree name from the branch name in the git picker in the title bar - Swap order of views in the git picker to "worktree | branches | stash" - Improve the "creating worktree" loading indicator --- - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- assets/icons/git_worktree.svg | 7 + assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/default-windows.json | 4 +- crates/agent_ui/src/agent_panel.rs | 65 ++- .../src/conversation_view/thread_view.rs | 2 +- crates/agent_ui/src/threads_archive_view.rs | 3 +- crates/git_ui/src/branch_picker.rs | 26 +- crates/git_ui/src/git_picker.rs | 38 +- crates/icons/src/icons.rs | 1 + .../src/sidebar_recent_projects.rs | 9 +- crates/sidebar/src/sidebar.rs | 382 +++++++++--------- crates/title_bar/src/title_bar.rs | 50 ++- crates/ui/src/components/ai/thread_item.rs | 250 ++++++------ crates/ui/src/components/list/list_item.rs | 29 +- 15 files changed, 410 insertions(+), 464 deletions(-) create mode 100644 assets/icons/git_worktree.svg diff --git a/assets/icons/git_worktree.svg b/assets/icons/git_worktree.svg new file mode 100644 index 0000000000000000000000000000000000000000..25b49bc69f34d8a742451709d4d4a164f29248b6 --- /dev/null +++ b/assets/icons/git_worktree.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 26144db389ef553c73e099926bcf7ff0868ffc52..95c709f86197685cb9fc0b987b43832bd6a279e6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1451,8 +1451,8 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateBranchesTab", - "alt-2": "git_picker::ActivateWorktreesTab", + "alt-1": "git_picker::ActivateWorktreesTab", + "alt-2": "git_picker::ActivateBranchesTab", "alt-3": "git_picker::ActivateStashTab", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index aa455cfb70ca1c6bc627cdc587d8d4980bd71397..a3577422f76d15ca0f7984a6db1259add9d8ded3 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1526,8 +1526,8 @@ { "context": "GitPicker", "bindings": { - "cmd-1": "git_picker::ActivateBranchesTab", - "cmd-2": "git_picker::ActivateWorktreesTab", + "cmd-1": "git_picker::ActivateWorktreesTab", + "cmd-2": "git_picker::ActivateBranchesTab", "cmd-3": "git_picker::ActivateStashTab", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0316bf08ffdf9df659707845038caf74072b75c4..58774f540b10f7de40b59738aaabb13c67aa553c 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1440,8 +1440,8 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateBranchesTab", - "alt-2": "git_picker::ActivateWorktreesTab", + "alt-1": "git_picker::ActivateWorktreesTab", + "alt-2": "git_picker::ActivateBranchesTab", "alt-3": "git_picker::ActivateStashTab", }, }, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d55bdb1a8af3c68c478227e040d40849fb47369a..ddee8e8d43839b4fea0aa35b9fcfedf3fc6f9673 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -75,8 +75,8 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, - PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, + Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, + KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; use workspace::{ @@ -2302,7 +2302,13 @@ impl AgentPanel { let default = AgentSettings::get_global(cx).new_thread_location; let start_thread_in = match default { NewThreadLocation::LocalProject => StartThreadIn::LocalProject, - NewThreadLocation::NewWorktree => StartThreadIn::NewWorktree, + NewThreadLocation::NewWorktree => { + if self.project_has_git_repository(cx) { + StartThreadIn::NewWorktree + } else { + StartThreadIn::LocalProject + } + } }; if self.start_thread_in != start_thread_in { self.start_thread_in = start_thread_in; @@ -4053,9 +4059,10 @@ impl AgentPanel { .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .child(agent_selector_menu) - .when(has_visible_worktrees, |this| { - this.child(self.render_start_thread_in_selector(cx)) - }), + .when( + has_visible_worktrees && self.project_has_git_repository(cx), + |this| this.child(self.render_start_thread_in_selector(cx)), + ), ) .child( h_flex() @@ -4134,41 +4141,31 @@ impl AgentPanel { match status { WorktreeCreationStatus::Creating => Some( h_flex() + .absolute() + .bottom_12() .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .py(DynamicSpacing::Base02.rems(cx)) - .gap_2() - .bg(cx.theme().colors().surface_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child(SpinnerLabel::new().size(LabelSize::Small)) + .p_2() + .gap_1() + .justify_center() + .bg(cx.theme().colors().editor_background) + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(3), + ) .child( - Label::new("Creating worktree…") + Label::new("Creating Worktree…") .color(Color::Muted) .size(LabelSize::Small), ) .into_any_element(), ), WorktreeCreationStatus::Error(message) => Some( - h_flex() - .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .py(DynamicSpacing::Base02.rems(cx)) - .gap_2() - .bg(cx.theme().colors().surface_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child( - Label::new(message.clone()) - .color(Color::Warning) - .size(LabelSize::Small) - .truncate(), - ) + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(message.clone()) .into_any_element(), ), } @@ -4611,7 +4608,6 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) - .children(self.render_worktree_creation_status(cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| { @@ -4668,6 +4664,7 @@ impl Render for AgentPanel { ActiveView::Configuration => parent.children(self.configuration.clone()), } }) + .children(self.render_worktree_creation_status(cx)) .children(self.render_trial_end_upsell(window, cx)); match self.active_view.which_font_size_used() { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index ef8a46e5749966ac0a616ecb7fd2f5b7bc5e4f83..8c8157a834cee5481013246ae4c71f84ae77f04c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1066,7 +1066,7 @@ impl ThreadView { .join(" "); let text = text.lines().next().unwrap_or("").trim(); if !text.is_empty() { - let title: SharedString = util::truncate_and_trailoff(text, 20).into(); + let title: SharedString = util::truncate_and_trailoff(text, 200).into(); thread.update(cx, |thread, cx| { thread.set_provisional_title(title, cx); })?; diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index e95607a5966d072d085e91247dc1c3a9fd580628..ef4e3ab5393b1045b4de15b348c3e01e07c366bc 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -575,7 +575,7 @@ impl ThreadsArchiveView { .when(can_unarchive, |this| { this.child( Button::new("unarchive-thread", "Restore") - .style(ButtonStyle::OutlinedGhost) + .style(ButtonStyle::Filled) .label_size(LabelSize::Small) .when(is_focused, |this| { this.key_binding( @@ -606,6 +606,7 @@ impl ThreadsArchiveView { "delete-thread", IconName::Trash, ) + .style(ButtonStyle::Filled) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 329f8e91e9e8a0994b2c3502b7c6c2013f28a936..cfb7b6bd2a2fb6c57f17244e0e57a4a637866418 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -16,10 +16,7 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use ui::{ - Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, - prelude::*, -}; +use ui::{Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; @@ -1084,21 +1081,6 @@ impl PickerDelegate for BranchListDelegate { ) } - fn render_header( - &self, - _window: &mut Window, - _cx: &mut Context>, - ) -> Option { - matches!(self.state, PickerState::List).then(|| { - let label = match self.branch_filter { - BranchFilter::All => "Branches", - BranchFilter::Remote => "Remotes", - }; - - ListHeader::new(label).inset(true).into_any_element() - }) - } - fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { if self.editor_position() == PickerEditorPosition::End { return None; @@ -1193,7 +1175,11 @@ impl PickerDelegate for BranchListDelegate { this.justify_between() .child({ let focus_handle = focus_handle.clone(); - Button::new("filter-remotes", "Filter Remotes") + let filter_label = match self.branch_filter { + BranchFilter::All => "Filter Remote", + BranchFilter::Remote => "Show All", + }; + Button::new("filter-remotes", filter_label) .toggle_state(matches!( self.branch_filter, BranchFilter::Remote diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 6cf82327b43abe6c3784e4ec8ca3d16161edfda7..bf9d122a7ec16b11c56fc45f59ff8c5f85f7fded 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -25,8 +25,8 @@ actions!( #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GitPickerTab { - Branches, Worktrees, + Branches, Stash, } @@ -190,9 +190,9 @@ impl GitPicker { fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Worktrees, - GitPickerTab::Worktrees => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Branches, + GitPickerTab::Worktrees => GitPickerTab::Branches, + GitPickerTab::Branches => GitPickerTab::Stash, + GitPickerTab::Stash => GitPickerTab::Worktrees, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -201,9 +201,9 @@ impl GitPicker { fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Worktrees => GitPickerTab::Branches, - GitPickerTab::Stash => GitPickerTab::Worktrees, + GitPickerTab::Worktrees => GitPickerTab::Stash, + GitPickerTab::Branches => GitPickerTab::Worktrees, + GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -241,9 +241,9 @@ impl GitPicker { "git-picker-tabs", [ ToggleButtonSimple::new( - GitPickerTab::Branches.to_string(), + GitPickerTab::Worktrees.to_string(), cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Branches; + this.tab = GitPickerTab::Worktrees; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -251,16 +251,16 @@ impl GitPicker { ) .tooltip(move |_, cx| { Tooltip::for_action_in( - "Toggle Branch Picker", - &ActivateBranchesTab, - &branches_focus_handle, + "Toggle Worktree Picker", + &ActivateWorktreesTab, + &worktrees_focus_handle, cx, ) }), ToggleButtonSimple::new( - GitPickerTab::Worktrees.to_string(), + GitPickerTab::Branches.to_string(), cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Worktrees; + this.tab = GitPickerTab::Branches; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -268,9 +268,9 @@ impl GitPicker { ) .tooltip(move |_, cx| { Tooltip::for_action_in( - "Toggle Worktree Picker", - &ActivateWorktreesTab, - &worktrees_focus_handle, + "Toggle Branch Picker", + &ActivateBranchesTab, + &branches_focus_handle, cx, ) }), @@ -297,8 +297,8 @@ impl GitPicker { .style(ToggleButtonGroupStyle::Outlined) .auto_width() .selected_index(match self.tab { - GitPickerTab::Branches => 0, - GitPickerTab::Worktrees => 1, + GitPickerTab::Worktrees => 0, + GitPickerTab::Branches => 1, GitPickerTab::Stash => 2, }), ) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d1450abdac49b34f240e375e9a4318d186c1f1da..3ca6b4f84d4f09fe2114d0bd86e1d30e6a30e1d1 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -151,6 +151,7 @@ pub enum IconName { GitCommit, GitGraph, GitMergeConflict, + GitWorktree, Github, Hash, HistoryRerun, diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 5ae8ee8bbf48c50c105251ea2ca08b3a88b05ec4..bef88557b12aa076658799ff0c08518c68b6e729 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -47,6 +47,7 @@ impl SidebarRecentProjects { workspaces: Vec::new(), filtered_workspaces: Vec::new(), selected_index: 0, + has_any_non_local_projects: false, focus_handle: cx.focus_handle(), }; @@ -122,6 +123,7 @@ pub struct SidebarRecentProjectsDelegate { )>, filtered_workspaces: Vec, selected_index: usize, + has_any_non_local_projects: bool, focus_handle: FocusHandle, } @@ -135,6 +137,9 @@ impl SidebarRecentProjectsDelegate { DateTime, )>, ) { + self.has_any_non_local_projects = workspaces + .iter() + .any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local)); self.workspaces = workspaces; } } @@ -383,7 +388,9 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { h_flex() .gap_3() .flex_grow() - .child(Icon::new(icon).color(Color::Muted)) + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) .child(highlighted_match.render(window, cx)), ) .tooltip(Tooltip::text(tooltip_path)) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6c4142df5c3f65919a701a29e9a2f7dbd3dd2216..4df6adaaa4d303402d622b393cb3899257d79f13 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -29,8 +29,7 @@ use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, - prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -110,6 +109,7 @@ struct ThreadEntry { is_title_generating: bool, highlight_positions: Vec, worktree_name: Option, + worktree_full_path: Option, worktree_highlight_positions: Vec, diff_stats: DiffStats, } @@ -127,7 +127,6 @@ enum ListEntry { Thread(ThreadEntry), ViewMore { path_list: PathList, - remaining_count: usize, is_fully_expanded: bool, }, NewThread { @@ -599,6 +598,19 @@ impl Sidebar { let query = self.filter_editor.read(cx).text(cx); + // Re-derive agent_panel_visible from the active workspace so it stays + // correct after workspace switches. + self.agent_panel_visible = active_workspace + .as_ref() + .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); + + // Derive active_thread_is_draft BEFORE focused_thread so we can + // use it as a guard below. + self.active_thread_is_draft = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); + // Derive focused_thread from the active workspace's agent panel. // Only update when the panel gives us a positive signal — if the // panel returns None (e.g. still loading after a thread activation), @@ -612,21 +624,10 @@ impl Sidebar { .active_conversation() .and_then(|cv| cv.read(cx).parent_id(cx)) }); - if panel_focused.is_some() { + if panel_focused.is_some() && !self.active_thread_is_draft { self.focused_thread = panel_focused; } - // Re-derive agent_panel_visible from the active workspace so it stays - // correct after workspace switches. - self.agent_panel_visible = active_workspace - .as_ref() - .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); - - self.active_thread_is_draft = active_workspace - .as_ref() - .and_then(|ws| ws.read(cx).panel::(cx)) - .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); - let previous = mem::take(&mut self.contents); let old_statuses: HashMap = previous @@ -756,6 +757,7 @@ impl Sidebar { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); @@ -842,6 +844,9 @@ impl Sidebar { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: Some(worktree_name.clone()), + worktree_full_path: Some( + worktree_path.display().to_string().into(), + ), worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); @@ -886,9 +891,7 @@ impl Sidebar { ThreadEntryWorkspace::Closed(_) => false, }; - if thread.is_background && thread.status == AgentThreadStatus::Completed { - notified_threads.insert(session_id.clone()); - } else if thread.status == AgentThreadStatus::Completed + if thread.status == AgentThreadStatus::Completed && !is_thread_workspace_active && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { @@ -965,6 +968,16 @@ impl Sidebar { entries.push(thread.into()); } } else { + let thread_count = threads.len(); + let is_draft_for_workspace = self.agent_panel_visible + && self.active_thread_is_draft + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace); + + let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace; + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), @@ -979,10 +992,12 @@ impl Sidebar { continue; } - entries.push(ListEntry::NewThread { - path_list: path_list.clone(), - workspace: workspace.clone(), - }); + if show_new_thread_entry { + entries.push(ListEntry::NewThread { + path_list: path_list.clone(), + workspace: workspace.clone(), + }); + } let total = threads.len(); @@ -1027,7 +1042,6 @@ impl Sidebar { if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { path_list: path_list.clone(), - remaining_count: total.saturating_sub(visible), is_fully_expanded, }); } @@ -1126,16 +1140,8 @@ impl Sidebar { ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx), ListEntry::ViewMore { path_list, - remaining_count, is_fully_expanded, - } => self.render_view_more( - ix, - path_list, - *remaining_count, - *is_fully_expanded, - is_selected, - cx, - ), + } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx), ListEntry::NewThread { path_list, workspace, @@ -1178,6 +1184,13 @@ impl Sidebar { IconName::ChevronDown }; + let has_new_thread_entry = self + .contents + .entries + .get(ix + 1) + .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. })); + let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx); + let workspace_for_remove = workspace.clone(); let workspace_for_menu = workspace.clone(); @@ -1200,10 +1213,27 @@ impl Sidebar { .into_any_element() }; - ListItem::new(id) - .height(Tab::content_height(cx)) - .group_name(group_name) - .focused(is_selected) + let color = cx.theme().colors(); + let hover_color = color + .element_active + .blend(color.element_background.opacity(0.2)); + + h_flex() + .id(id) + .group(&group_name) + .h(Tab::content_height(cx)) + .w_full() + .px_1p5() + .border_1() + .map(|this| { + if is_selected { + this.border_color(color.border_focused) + } else { + this.border_color(gpui::transparent_black()) + } + }) + .justify_between() + .hover(|s| s.bg(hover_color)) .child( h_flex() .relative() @@ -1214,7 +1244,7 @@ impl Sidebar { h_flex().size_4().flex_none().justify_center().child( Icon::new(disclosure_icon) .size(IconSize::Small) - .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), + .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))), ), ) .child(label) @@ -1244,11 +1274,13 @@ impl Sidebar { ) }), ) - .end_hover_gradient_overlay(true) - .end_slot({ + .child({ + let workspace_for_new_thread = workspace.clone(); + let path_list_for_new_thread = path_list.clone(); + h_flex() .when(self.project_header_menu_ix != Some(ix), |this| { - this.visible_on_hover("list_item") + this.visible_on_hover(group_name) }) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); @@ -1300,6 +1332,30 @@ impl Sidebar { )), ) }) + .when(show_new_thread_button, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-new-thread-{ix}", + )), + IconName::Plus, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener({ + let workspace_for_new_thread = workspace_for_new_thread.clone(); + let path_list_for_new_thread = path_list_for_new_thread.clone(); + move |this, _, window, cx| { + // Uncollapse the group if collapsed so + // the new-thread entry becomes visible. + this.collapsed_groups.remove(&path_list_for_new_thread); + this.selection = None; + this.create_new_thread(&workspace_for_new_thread, window, cx); + } + })), + ) + }) }) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; @@ -1513,7 +1569,7 @@ impl Sidebar { let color = cx.theme().colors(); let background = color .title_bar_background - .blend(color.panel_background.opacity(0.8)); + .blend(color.panel_background.opacity(0.2)); let element = v_flex() .absolute() @@ -2348,17 +2404,21 @@ impl Sidebar { ThreadItem::new(id, title) .icon(thread.icon) + .status(thread.status) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) .when_some(thread.worktree_name.clone(), |this, name| { - this.worktree(name) + let this = this.worktree(name); + match thread.worktree_full_path.clone() { + Some(path) => this.worktree_full_path(path), + None => this, + } }) .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) - .status(thread.status) - .generating_title(thread.is_title_generating) + .title_generating(thread.is_title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) @@ -2521,7 +2581,6 @@ impl Sidebar { &self, ix: usize, path_list: &PathList, - remaining_count: usize, is_fully_expanded: bool, is_selected: bool, cx: &mut Context, @@ -2529,23 +2588,15 @@ impl Sidebar { let path_list = path_list.clone(); let id = SharedString::from(format!("view-more-{}", ix)); - let icon = if is_fully_expanded { - IconName::ListCollapse - } else { - IconName::Plus - }; - 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) + .icon_visible(false) .title_label_color(Color::Muted) .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; @@ -2650,9 +2701,9 @@ impl Sidebar { let thread_item = ThreadItem::new(id, label) .icon(IconName::Plus) + .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) .selected(is_active) .focused(is_selected) - .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85))) .when(!is_active, |this| { this.on_click(cx.listener(move |this, _, window, cx| { this.selection = None; @@ -2927,11 +2978,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() + + let color = cx.theme().colors(); + let bg = color .title_bar_background - .blend(cx.theme().colors().panel_background.opacity(0.8)); + .blend(color.panel_background.opacity(0.32)); let no_open_projects = !self.contents.has_open_projects; let no_search_results = self.contents.entries.is_empty(); @@ -2965,7 +3016,7 @@ impl Render for Sidebar { .w(self.width) .bg(bg) .border_r_1() - .border_color(cx.theme().colors().border) + .border_color(color.border) .map(|this| match &self.view { SidebarView::ThreadList => this .child(self.render_sidebar_header(no_open_projects, window, cx)) @@ -3240,14 +3291,12 @@ mod tests { ) } ListEntry::ViewMore { - remaining_count, - is_fully_expanded, - .. + is_fully_expanded, .. } => { if *is_fully_expanded { format!(" - Collapse{}", selected) } else { - format!(" + View More ({}){}", remaining_count, selected) + format!(" + View More{}", selected) } } ListEntry::NewThread { .. } => { @@ -3345,7 +3394,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread]", " Fix crash in project panel", " Add inline diff view", ] @@ -3377,7 +3425,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1"] + vec!["v [project-a]", " Thread A1"] ); // Add a second workspace @@ -3388,7 +3436,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1",] + vec!["v [project-a]", " Thread A1",] ); // Remove the second workspace @@ -3399,7 +3447,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1"] + vec!["v [project-a]", " Thread A1"] ); } @@ -3420,13 +3468,12 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread]", " Thread 12", " Thread 11", " Thread 10", " Thread 9", " Thread 8", - " + View More (7)", + " + View More", ] ); } @@ -3445,23 +3492,23 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Initially shows NewThread + 5 threads + View More (12 remaining) + // Initially shows 5 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (12)"))); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Focus and navigate to View More, then confirm to expand by one batch open_and_focus_sidebar(&sidebar, cx); - for _ in 0..8 { + for _ in 0..7 { cx.dispatch_action(SelectNext); } cx.dispatch_action(Confirm); cx.run_until_parked(); - // Now shows NewThread + 10 threads + View More (7 remaining) + // Now shows 10 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 13); // header + NewThread + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (7)"))); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Expand again by one batch sidebar.update_in(cx, |s, _window, cx| { @@ -3471,10 +3518,10 @@ mod tests { }); cx.run_until_parked(); - // Now shows NewThread + 15 threads + View More (2 remaining) + // Now shows 15 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 18); // header + NewThread + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (2)"))); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Expand one more time - should show all 17 threads with Collapse button sidebar.update_in(cx, |s, _window, cx| { @@ -3486,7 +3533,7 @@ mod tests { // All 17 threads shown with Collapse button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 20); // header + NewThread + 17 threads + Collapse + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); @@ -3497,10 +3544,10 @@ mod tests { }); cx.run_until_parked(); - // Back to initial state: NewThread + 5 threads + View More (12 remaining) + // Back to initial state: 5 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (12)"))); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); } #[gpui::test] @@ -3518,7 +3565,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Collapse @@ -3540,7 +3587,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); } @@ -3570,7 +3617,6 @@ mod tests { has_running_threads: false, waiting_thread_count: 0, }, - // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { @@ -3590,6 +3636,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3613,6 +3660,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3636,6 +3684,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3659,6 +3708,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3682,13 +3732,13 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { path_list: expanded_path.clone(), - remaining_count: 42, is_fully_expanded: false, }, // Collapsed project header @@ -3701,6 +3751,7 @@ mod tests { waiting_thread_count: 0, }, ]; + // Select the Running thread (index 2) s.selection = Some(2); }); @@ -3714,7 +3765,7 @@ mod tests { " Error thread * (error)", " Waiting thread (waiting)", " Notified thread * (!)", - " + View More (42)", + " + View More", "> [collapsed-project]", ] ); @@ -3758,7 +3809,7 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Entries: [header, new_thread, thread3, thread2, thread1] + // Entries: [header, 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); @@ -3778,9 +3829,6 @@ 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, wraps back to first entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); @@ -3792,13 +3840,8 @@ mod tests { assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); 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)); // 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)); @@ -3829,7 +3872,7 @@ mod tests { // SelectLast jumps to the end cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); // SelectFirst jumps to the beginning cx.dispatch_action(SelectFirst); @@ -3882,7 +3925,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Focus the sidebar and select the header (index 0) @@ -3906,11 +3949,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project] <== selected", - " [+ New Thread]", - " Thread 1", - ] + vec!["v [my-project] <== selected", " Thread 1",] ); } @@ -3926,17 +3965,17 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Should show header + NewThread + 5 threads + "View More (3)" + // Should show header + 5 threads + "View More" let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); - assert!(entries.iter().any(|e| e.contains("View More (3)"))); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 7) + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) open_and_focus_sidebar(&sidebar, cx); - for _ in 0..8 { + for _ in 0..7 { cx.dispatch_action(SelectNext); } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(7)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); // Confirm on "View More" to expand cx.dispatch_action(Confirm); @@ -3944,7 +3983,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(), 11); // header + NewThread + 8 threads + Collapse button + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); } @@ -3963,7 +4002,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Focus sidebar and manually select the header (index 0). Press left to collapse. @@ -3986,11 +4025,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project] <== selected", - " [+ New Thread]", - " Thread 1", - ] + vec!["v [my-project] <== selected", " Thread 1",] ); // Press right again on already-expanded header moves selection down @@ -4014,16 +4049,11 @@ mod tests { open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Thread 1 <== selected", - ] + vec!["v [my-project]", " Thread 1 <== selected",] ); // Pressing left on a child collapses the parent group and selects it @@ -4044,7 +4074,7 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - // Even an empty project has the header and a new thread button + // An empty project has the header and a new thread button. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [empty-project]", " [+ New Thread]"] @@ -4083,12 +4113,11 @@ 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 2) + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); // Collapse the group, which removes the thread from the list cx.dispatch_action(SelectParent); @@ -4188,15 +4217,10 @@ mod tests { cx.run_until_parked(); let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[2..].sort(); + entries[1..].sort(); assert_eq!( entries, - vec![ - "v [my-project]", - " [+ New Thread]", - " Hello *", - " Hello * (running)", - ] + vec!["v [my-project]", " Hello *", " Hello * (running)",] ); } @@ -4237,7 +4261,7 @@ mod tests { // Thread A is still running; no notification yet. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello * (running)",] + vec!["v [project-a]", " Hello * (running)",] ); // Complete thread A's turn (transition Running → Completed). @@ -4247,7 +4271,7 @@ mod tests { // The completed background thread shows a notification indicator. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello * (!)",] + vec!["v [project-a]", " Hello * (!)",] ); } @@ -4290,7 +4314,6 @@ 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", @@ -4381,12 +4404,7 @@ mod tests { // Confirm the full list is showing. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Alpha thread", - " Beta thread", - ] + vec!["v [my-project]", " Alpha thread", " Beta thread",] ); // User types a search query to filter down. @@ -4398,16 +4416,14 @@ 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. + // The selection index (1) now points at the first thread entry. cx.dispatch_action(Cancel); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread] <== selected", - " Alpha thread", + " Alpha thread <== selected", " Beta thread", ] ); @@ -4463,7 +4479,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", - " [+ New Thread]", " Fix bug in sidebar", " Add tests for editor", ] @@ -4781,7 +4796,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Historical Thread",] + vec!["v [my-project]", " Historical Thread",] ); // Switch to workspace 1 so we can verify the confirm switches back. @@ -4843,22 +4858,17 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Thread A", - " Thread B", - ] + vec!["v [my-project]", " Thread A", " Thread B",] ); // Keyboard confirm preserves selection. sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(2); + sidebar.selection = Some(1); sidebar.confirm(&Confirm, window, cx); }); assert_eq!( sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(2) + Some(1) ); // Click handlers clear selection to None so no highlight lingers @@ -4901,7 +4911,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"] + vec!["v [my-project]", " Hello *"] ); // Simulate the agent generating a title. The notification chain is: @@ -4923,11 +4933,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Friendly Greeting with AI *" - ] + vec!["v [my-project]", " Friendly Greeting with AI *"] ); } @@ -5179,7 +5185,7 @@ mod tests { // Verify the thread appears in the sidebar. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello *",] + vec!["v [project-a]", " Hello *",] ); // The "New Thread" button should NOT be in "active/draft" state @@ -5340,11 +5346,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Worktree Thread {rosewood}", - ] + vec!["v [project]", " Worktree Thread {rosewood}",] ); } @@ -5421,10 +5423,8 @@ 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", ] ); @@ -5461,7 +5461,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", - " [+ New Thread]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", ] @@ -5482,11 +5481,7 @@ mod tests { // under the main repo. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Thread A {wt-feature-a}", - ] + vec!["v [project]", " Thread A {wt-feature-a}",] ); } @@ -5603,11 +5598,7 @@ mod tests { let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!( entries, - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); } @@ -5706,11 +5697,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -5718,11 +5705,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (!)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (!)",] ); } @@ -5790,11 +5773,7 @@ mod tests { // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " WT Thread {wt-feature-a}" - ], + vec!["v [project]", " WT Thread {wt-feature-a}"], ); // Only 1 workspace should exist. @@ -5806,7 +5785,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(2); // index 0 is header, 1 is NewThread, 2 is the thread + sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); // Confirm to open the worktree thread. @@ -5911,9 +5890,8 @@ mod tests { // The worktree workspace should be absorbed under the main repo. let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 4); + assert_eq!(entries.len(), 3); 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 b5fdece055d2c7f80421d361a27a5a93d62e3420..9c12e0ca5a0042d7679f5807bab81efbe0ead1eb 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -908,14 +908,7 @@ impl TitleBar { }; let branch_name = branch_name?; - let button_text = if let Some(worktree_name) = linked_worktree_name { - format!("{}/{}", worktree_name, branch_name) - } else { - branch_name - }; - let settings = TitleBarSettings::get_global(cx); - let effective_repository = Some(repository); Some( @@ -931,21 +924,42 @@ impl TitleBar { )) }) .trigger_with_tooltip( - Button::new("project_branch_trigger", button_text) + ButtonLike::new("project_branch_trigger") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .label_size(LabelSize::Small) - .color(Color::Muted) - .when(settings.show_branch_icon, |branch_button| { - let (icon, icon_color) = icon_info; - branch_button.start_icon( - Icon::new(icon).size(IconSize::Indicator).color(icon_color), - ) - }), + .child( + h_flex() + .gap_0p5() + .when(settings.show_branch_icon, |this| { + let (icon, icon_color) = icon_info; + this.child( + Icon::new(icon).size(IconSize::XSmall).color(icon_color), + ) + }) + .when_some(linked_worktree_name.as_ref(), |this, worktree_name| { + this.child( + Label::new(worktree_name) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("/").size(LabelSize::Small).color( + Color::Custom( + cx.theme().colors().text_muted.opacity(0.4), + ), + ), + ) + }) + .child( + Label::new(branch_name) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), move |_window, cx| { Tooltip::with_meta( - "Recent Branches", + "Git Switcher", Some(&zed_actions::git::Branch), - "Local branches only", + "Worktrees, Branches, and Stashes", cx, ) }, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 02de8512963302ddeb1abce572894caf4dadd616..875f73ed892fcce6a152ca21f5a661d262c02ad8 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -22,25 +22,26 @@ pub enum AgentThreadStatus { pub struct ThreadItem { id: ElementId, icon: IconName, + icon_color: Option, + icon_visible: bool, custom_icon_from_external_svg: Option, title: SharedString, + title_label_color: Option, + title_generating: bool, + highlight_positions: Vec, timestamp: SharedString, notified: bool, status: AgentThreadStatus, - generating_title: bool, selected: bool, focused: bool, hovered: bool, - docked_right: bool, added: Option, removed: Option, worktree: Option, - highlight_positions: Vec, + worktree_full_path: Option, worktree_highlight_positions: Vec, on_click: Option>, on_hover: Box, - title_label_color: Option, - title_label_size: Option, action_slot: Option, tooltip: Option AnyView + 'static>>, } @@ -50,25 +51,26 @@ impl ThreadItem { Self { id: id.into(), icon: IconName::ZedAgent, + icon_color: None, + icon_visible: true, custom_icon_from_external_svg: None, title: title.into(), + title_label_color: None, + title_generating: false, + highlight_positions: Vec::new(), timestamp: "".into(), notified: false, status: AgentThreadStatus::default(), - generating_title: false, selected: false, focused: false, hovered: false, - docked_right: false, added: None, removed: None, worktree: None, - highlight_positions: Vec::new(), + worktree_full_path: None, worktree_highlight_positions: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), - title_label_color: None, - title_label_size: None, action_slot: None, tooltip: None, } @@ -84,6 +86,16 @@ impl ThreadItem { self } + pub fn icon_color(mut self, color: Color) -> Self { + self.icon_color = Some(color); + self + } + + pub fn icon_visible(mut self, visible: bool) -> Self { + self.icon_visible = visible; + self + } + pub fn custom_icon_from_external_svg(mut self, svg: impl Into) -> Self { self.custom_icon_from_external_svg = Some(svg.into()); self @@ -99,8 +111,18 @@ impl ThreadItem { self } - pub fn generating_title(mut self, generating: bool) -> Self { - self.generating_title = generating; + pub fn title_generating(mut self, generating: bool) -> Self { + self.title_generating = generating; + self + } + + pub fn title_label_color(mut self, color: Color) -> Self { + self.title_label_color = Some(color); + self + } + + pub fn highlight_positions(mut self, positions: Vec) -> Self { + self.highlight_positions = positions; self } @@ -124,18 +146,13 @@ impl ThreadItem { self } - pub fn docked_right(mut self, docked_right: bool) -> Self { - self.docked_right = docked_right; - self - } - pub fn worktree(mut self, worktree: impl Into) -> Self { self.worktree = Some(worktree.into()); self } - pub fn highlight_positions(mut self, positions: Vec) -> Self { - self.highlight_positions = positions; + pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { + self.worktree_full_path = Some(worktree_full_path.into()); self } @@ -162,16 +179,6 @@ impl ThreadItem { self } - pub fn title_label_color(mut self, color: Color) -> Self { - self.title_label_color = Some(color); - self - } - - pub fn title_label_size(mut self, size: LabelSize) -> Self { - self.title_label_size = Some(size); - self - } - pub fn action_slot(mut self, element: impl IntoElement) -> Self { self.action_slot = Some(element.into_any_element()); self @@ -186,6 +193,26 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); + let base_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.2)); + + let base_bg = if self.selected { + color.element_active + } else { + base_bg + }; + + let hover_color = color + .element_active + .blend(color.element_background.opacity(0.2)); + + let gradient_overlay = GradientFade::new(base_bg, hover_color, hover_color) + .width(px(64.0)) + .right(px(-10.0)) + .gradient_stop(0.75) + .group_name("thread-item"); + let dot_separator = || { Label::new("•") .size(LabelSize::Small) @@ -194,25 +221,26 @@ impl RenderOnce for ThreadItem { }; let icon_id = format!("icon-{}", self.id); + let icon_visible = self.icon_visible; let icon_container = || { h_flex() .id(icon_id.clone()) .size_4() .flex_none() .justify_center() + .when(!icon_visible, |this| this.invisible()) }; + let icon_color = self.icon_color.unwrap_or(Color::Muted); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { Icon::from_external_svg(custom_svg) - .color(Color::Muted) + .color(icon_color) .size(IconSize::Small) } else { - Icon::new(self.icon) - .color(Color::Muted) - .size(IconSize::Small) + Icon::new(self.icon).color(icon_color).size(IconSize::Small) }; let decoration = |icon: IconDecorationKind, color: Hsla| { - IconDecoration::new(icon, cx.theme().colors().surface_background, cx) + IconDecoration::new(icon, base_bg, cx) .color(color) .position(gpui::Point { x: px(-2.), @@ -264,10 +292,9 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label_size = self.title_label_size.unwrap_or(LabelSize::Default); - let title_label = if self.generating_title { + + let title_label = if self.title_generating { Label::new(title) - .size(title_label_size) .color(Color::Muted) .with_animation( "generating-title", @@ -278,66 +305,38 @@ impl RenderOnce for ThreadItem { ) .into_any_element() } else if highlight_positions.is_empty() { - let label = Label::new(title).size(title_label_size); - let label = if let Some(color) = self.title_label_color { - label.color(color) - } else { - label - }; - label.into_any_element() - } else { - let label = HighlightedLabel::new(title, highlight_positions).size(title_label_size); - 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 + Label::new(title) + .when_some(self.title_label_color, |label, color| label.color(color)) + .into_any_element() } else { - b_bg + HighlightedLabel::new(title, highlight_positions) + .when_some(self.title_label_color, |label, color| label.color(color)) + .into_any_element() }; - let gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(64.0)) - .right(px(-10.0)) - .gradient_stop(0.75) - .group_name("thread-item"); - let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let diff_stat_id = self.id.clone(); let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; v_flex() .id(self.id.clone()) + .cursor_pointer() .group("thread-item") .relative() .overflow_hidden() - .cursor_pointer() .w_full() .py_1() .px_1p5() .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.focused, |s| { - s.when(self.docked_right, |s| s.border_r_2()) - .border_color(color.border_focused) - }) - .hover(|s| s.bg(color.element_hover)) - .active(|s| s.bg(color.element_active)) + .when(self.focused, |s| s.border_color(color.border_focused)) + .hover(|s| s.bg(hover_color)) .on_hover(self.on_hover) .child( h_flex() @@ -358,15 +357,11 @@ impl RenderOnce for ThreadItem { .child(gradient_overlay) .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { - let overlay = GradientFade::new( - base_bg, - color.element_hover, - color.element_active, - ) - .width(px(64.0)) - .right(px(6.)) - .gradient_stop(0.75) - .group_name("thread-item"); + let overlay = GradientFade::new(base_bg, hover_color, hover_color) + .width(px(64.0)) + .right(px(6.)) + .gradient_stop(0.75) + .group_name("thread-item"); this.child( h_flex() @@ -380,57 +375,56 @@ impl RenderOnce for ThreadItem { }) }), ) - .when_some(self.worktree, |this, worktree| { - let worktree_highlight_positions = self.worktree_highlight_positions; - let worktree_label = if worktree_highlight_positions.is_empty() { - Label::new(worktree) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - } else { - HighlightedLabel::new(worktree, worktree_highlight_positions) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - }; + .when(has_worktree || has_diff_stats || has_timestamp, |this| { + let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); + let worktree_label = self.worktree.map(|worktree| { + let positions = self.worktree_highlight_positions; + if positions.is_empty() { + Label::new(worktree) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(worktree, positions) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } + }); this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .child(worktree_label) - .when(has_diff_stats || has_timestamp, |this| { - this.child(dot_separator()) - }) - .when(has_diff_stats, |this| { + .when_some(worktree_label, |this, label| { this.child( - DiffStat::new(diff_stat_id.clone(), added_count, removed_count) - .tooltip("Unreviewed changes"), + h_flex() + .id(format!("{}-worktree", self.id.clone())) + .gap_1() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(label) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Thread Running in a Local Git Worktree", + None, + worktree_full_path.clone(), + cx, + ) + }), ) }) - .when(has_diff_stats && has_timestamp, |this| { + .when(has_worktree && (has_diff_stats || has_timestamp), |this| { this.child(dot_separator()) }) - .when(has_timestamp, |this| { - this.child( - Label::new(timestamp.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - }) - .when(!has_worktree && (has_diff_stats || has_timestamp), |this| { - this.child( - h_flex() - .min_w_0() - .gap_1p5() - .child(icon_container()) // Icon Spacing .when(has_diff_stats, |this| { this.child( DiffStat::new(diff_stat_id, added_count, removed_count) - .tooltip("Unreviewed Changes"), + .tooltip("Unreviewed changes"), ) }) .when(has_diff_stats && has_timestamp, |this| { @@ -583,18 +577,6 @@ impl Component for ThreadItem { ) .into_any_element(), ), - single_example( - "Focused + Docked Right", - container() - .child( - ThreadItem::new("ti-7b", "Focused with right dock border") - .icon(IconName::AiClaude) - .timestamp("1w") - .focused(true) - .docked_right(true), - ) - .into_any_element(), - ), single_example( "Selected + Focused", container() diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 3eb21c3429d428675774d96a9969542536c31a26..693cf3d52e34369d04db445d1ddac765691fb429 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -4,7 +4,7 @@ use component::{Component, ComponentScope, example_group_with_title, single_exam use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px}; use smallvec::SmallVec; -use crate::{Disclosure, GradientFade, prelude::*}; +use crate::{Disclosure, prelude::*}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ListItemSpacing { @@ -31,9 +31,6 @@ pub struct ListItem { /// A slot for content that appears on hover after the children /// It will obscure the `end_slot` when visible. end_hover_slot: Option, - /// When true, renders a gradient fade overlay before the `end_hover_slot` - /// to smoothly truncate overflowing content. - end_hover_gradient_overlay: bool, toggle: Option, inset: bool, on_click: Option>, @@ -65,7 +62,6 @@ impl ListItem { start_slot: None, end_slot: None, end_hover_slot: None, - end_hover_gradient_overlay: false, toggle: None, inset: false, on_click: None, @@ -174,11 +170,6 @@ impl ListItem { self } - pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self { - self.end_hover_gradient_overlay = show; - self - } - pub fn outlined(mut self) -> Self { self.outlined = true; self @@ -232,21 +223,6 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let color = cx.theme().colors(); - - let base_bg = if self.selected { - color.element_active - } else { - color.panel_background - }; - - let end_hover_gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(96.0)) - .when_some(self.group_name.clone(), |fade, group| { - fade.group_name(group) - }); - h_flex() .id(self.id) .when_some(self.group_name, |this, group| this.group(group)) @@ -382,9 +358,6 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") - .when(self.end_hover_gradient_overlay, |this| { - this.child(end_hover_gradient_overlay) - }) .child(end_hover_slot), ) }),