Cargo.lock π
@@ -15870,6 +15870,7 @@ dependencies = [
"pretty_assertions",
"project",
"prompt_store",
+ "recent_projects",
"serde_json",
"settings",
"theme",
Danilo Leal and cameron created
- Selection/focus improvements (reduces flickers, move selection more
correctly throughout the list)
- Adds open folder button in the sidebar header
- Fixes sidebar header design, including the thread view one, too
- Fixes behavior of cmd-n when focused in the sidebar
- Changes the design for the "new thread" button in the sidebar and adds
a preview of the prompt on it
- Rename items in the "start thread in" dropdown
Release Notes:
- N/A
---------
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Cargo.lock | 1
assets/keymaps/default-linux.json | 2
assets/keymaps/default-macos.json | 2
assets/keymaps/default-windows.json | 2
crates/agent_ui/src/agent_panel.rs | 23
crates/agent_ui/src/threads_archive_view.rs | 63
crates/sidebar/Cargo.toml | 1
crates/sidebar/src/sidebar.rs | 751 +++++++++----
crates/title_bar/src/title_bar.rs | 100 +
crates/ui/src/components/ai.rs | 2
crates/ui/src/components/ai/thread_item.rs | 46
crates/ui/src/components/ai/thread_sidebar_toggle.rs | 177 ---
crates/workspace/src/multi_workspace.rs | 26
13 files changed, 728 insertions(+), 468 deletions(-)
@@ -15870,6 +15870,7 @@ dependencies = [
"pretty_assertions",
"project",
"prompt_store",
+ "recent_projects",
"serde_json",
"settings",
"theme",
@@ -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",
@@ -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",
@@ -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",
@@ -601,8 +601,8 @@ impl From<Agent> for AgentType {
impl StartThreadIn {
fn label(&self) -> SharedString {
match self {
- Self::LocalProject => "Current Project".into(),
- Self::NewWorktree => "New Worktree".into(),
+ Self::LocalProject => "Current Worktree".into(),
+ Self::NewWorktree => "New Git Worktree".into(),
}
}
}
@@ -1951,6 +1951,21 @@ impl AgentPanel {
self.background_threads.contains_key(session_id)
}
+ pub fn cancel_thread(&self, session_id: &acp::SessionId, cx: &mut Context<Self>) -> bool {
+ let conversation_views = self
+ .active_conversation_view()
+ .into_iter()
+ .chain(self.background_threads.values());
+
+ for conversation_view in conversation_views {
+ if let Some(thread_view) = conversation_view.read(cx).thread_view(session_id) {
+ thread_view.update(cx, |view, cx| view.cancel_generation(cx));
+ return true;
+ }
+ }
+ false
+ }
+
/// active thread plus any background threads that are still running or
/// completed but unseen.
pub fn parent_threads(&self, cx: &App) -> Vec<Entity<ThreadView>> {
@@ -3551,7 +3566,7 @@ impl AgentPanel {
menu.header("Start Thread Inβ¦")
.item(
- ContextMenuEntry::new("Current Project")
+ ContextMenuEntry::new("Current Worktree")
.toggleable(IconPosition::End, is_local_selected)
.documentation_aside(documentation_side, move |_| {
HoldForDefault::new(is_local_default)
@@ -3579,7 +3594,7 @@ impl AgentPanel {
}),
)
.item({
- let entry = ContextMenuEntry::new("New Worktree")
+ let entry = ContextMenuEntry::new("New Git Worktree")
.toggleable(IconPosition::End, is_new_worktree_selected)
.disabled(new_worktree_disabled)
.handler({
@@ -21,6 +21,7 @@ use theme::ActiveTheme;
use ui::{
ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+ utils::platform_title_bar_height,
};
use util::ResultExt as _;
use zed_actions::editor::{MoveDown, MoveUp};
@@ -676,32 +677,56 @@ impl ThreadsArchiveView {
})
}
- fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
+ let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+ let header_height = platform_title_bar_height(window);
- h_flex()
- .h(Tab::container_height(cx))
- .px_1()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
+ v_flex()
.child(
h_flex()
- .flex_1()
- .w_full()
+ .h(header_height)
+ .mt_px()
+ .pb_px()
+ .when(traffic_lights, |this| {
+ this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
+ })
+ .pr_1p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_1p5()
+ .child(
+ IconButton::new("back", IconName::ArrowLeft)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Back to Sidebar"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.go_back(window, cx);
+ })),
+ )
+ .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()),
+ )
+ .child(self.render_agent_picker(cx)),
+ )
+ .child(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .p_2()
+ .pr_1p5()
.gap_1p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
.child(
- IconButton::new("back", IconName::ArrowLeft)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Back to Sidebar"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.go_back(window, cx);
- })),
+ Icon::new(IconName::MagnifyingGlass)
+ .size(IconSize::Small)
+ .color(Color::Muted),
)
.child(self.filter_editor.clone())
.when(has_query, |this| {
- this.border_r_1().child(
- IconButton::new("clear_archive_filter", IconName::Close)
+ this.child(
+ IconButton::new("clear_filter", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Clear Search"))
.on_click(cx.listener(|this, _, window, cx| {
@@ -711,7 +736,6 @@ impl ThreadsArchiveView {
)
}),
)
- .child(self.render_agent_picker(cx))
}
}
@@ -783,8 +807,7 @@ impl Render for ThreadsArchiveView {
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::remove_selected_thread))
.size_full()
- .bg(cx.theme().colors().surface_background)
- .child(self.render_header(cx))
+ .child(self.render_header(window, cx))
.child(content)
}
}
@@ -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
@@ -14,6 +14,8 @@ use gpui::{
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::{AgentId, Event as ProjectEvent};
+use recent_projects::RecentProjects;
+use ui::utils::platform_title_bar_height;
use std::collections::{HashMap, HashSet};
use std::mem;
@@ -21,16 +23,17 @@ use std::path::Path;
use std::sync::Arc;
use theme::ActiveTheme;
use ui::{
- AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
- Tooltip, WithScrollbar, prelude::*,
+ AgentThreadStatus, ButtonStyle, HighlightedLabel, KeyBinding, ListItem, PopoverMenu,
+ PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
- MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar,
- Workspace,
+ FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
+ ToggleWorkspaceSidebar, Workspace,
};
+use zed_actions::OpenRecent;
use zed_actions::editor::{MoveDown, MoveUp};
actions!(
@@ -40,6 +43,8 @@ actions!(
CollapseSelectedEntry,
/// Expands the selected entry in the workspace sidebar.
ExpandSelectedEntry,
+ /// Creates a new thread in the currently selected or active project group.
+ NewThreadInGroup,
]
);
@@ -110,7 +115,6 @@ enum ListEntry {
label: SharedString,
workspace: Entity<Workspace>,
highlight_positions: Vec<usize>,
- has_threads: bool,
},
Thread(ThreadEntry),
ViewMore {
@@ -222,14 +226,22 @@ pub struct Sidebar {
/// Note: This is NOT the same as the active item.
selection: Option<usize>,
focused_thread: Option<acp::SessionId>,
+ /// Set to true when WorkspaceRemoved fires so the subsequent
+ /// ActiveWorkspaceChanged event knows not to clear focused_thread.
+ /// A workspace removal changes the active workspace as a side-effect, but
+ /// that should not reset the user's thread focus the way an explicit
+ /// workspace switch does.
+ pending_workspace_removal: bool,
active_entry_index: Option<usize>,
hovered_thread_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
view: SidebarView,
archive_view: Option<Entity<ThreadsArchiveView>>,
+ recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
_subscriptions: Vec<gpui::Subscription>,
_update_entries_task: Option<gpui::Task<()>>,
+ _draft_observation: Option<gpui::Subscription>,
}
impl Sidebar {
@@ -253,7 +265,29 @@ impl Sidebar {
window,
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
- this.focused_thread = None;
+ // Don't clear focused_thread when the active workspace
+ // changed because a workspace was removed β the focused
+ // thread may still be valid in the new active workspace.
+ // Only clear it for explicit user-initiated switches.
+ if mem::take(&mut this.pending_workspace_removal) {
+ // If the removed workspace had no focused thread, seed
+ // from the new active panel so its current thread gets
+ // highlighted β same logic as subscribe_to_workspace.
+ if this.focused_thread.is_none() {
+ if let Some(mw) = this.multi_workspace.upgrade() {
+ let ws = mw.read(cx).workspace();
+ if let Some(panel) = ws.read(cx).panel::<AgentPanel>(cx) {
+ this.focused_thread = panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ }
+ }
+ }
+ } else {
+ this.focused_thread = None;
+ }
+ this.observe_draft_editor(cx);
this.update_entries(false, cx);
}
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -261,6 +295,9 @@ impl Sidebar {
this.update_entries(false, cx);
}
MultiWorkspaceEvent::WorkspaceRemoved(_) => {
+ // Signal that the upcoming ActiveWorkspaceChanged event is
+ // a consequence of this removal, not a user workspace switch.
+ this.pending_workspace_removal = true;
this.update_entries(false, cx);
}
},
@@ -306,18 +343,21 @@ impl Sidebar {
contents: SidebarContents::default(),
selection: None,
focused_thread: None,
+ pending_workspace_removal: false,
active_entry_index: None,
hovered_thread_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
view: SidebarView::default(),
archive_view: None,
+ recent_projects_popover_handle: PopoverMenuHandle::default(),
_subscriptions: Vec::new(),
+ _draft_observation: None,
}
}
fn subscribe_to_workspace(
- &self,
+ &mut self,
workspace: &Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -372,11 +412,19 @@ impl Sidebar {
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
self.subscribe_to_agent_panel(&agent_panel, window, cx);
+ // Seed the initial focused_thread so the correct thread item is
+ // highlighted right away, without waiting for the panel to emit
+ // an event (which only happens on *changes*, not on first load).
+ self.focused_thread = agent_panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ self.observe_draft_editor(cx);
}
}
fn subscribe_to_agent_panel(
- &self,
+ &mut self,
agent_panel: &Entity<AgentPanel>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -384,32 +432,114 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
- |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
- AgentPanelEvent::ActiveViewChanged => {
- this.focused_thread = agent_panel
- .read(cx)
- .active_conversation()
- .and_then(|cv| cv.read(cx).parent_id(cx));
- this.update_entries(false, cx);
- }
- AgentPanelEvent::ThreadFocused => {
- let new_focused = agent_panel
- .read(cx)
- .active_conversation()
- .and_then(|cv| cv.read(cx).parent_id(cx));
- if new_focused.is_some() && new_focused != this.focused_thread {
- this.focused_thread = new_focused;
+ |this, agent_panel, event: &AgentPanelEvent, _window, cx| {
+ // Check whether the panel that emitted this event belongs to
+ // the currently active workspace. Only the active workspace's
+ // panel should drive focused_thread β otherwise running threads
+ // in background workspaces would continuously overwrite it,
+ // causing the selection highlight to jump around.
+ let is_active_panel = this
+ .multi_workspace
+ .upgrade()
+ .and_then(|mw| mw.read(cx).workspace().read(cx).panel::<AgentPanel>(cx))
+ .is_some_and(|active_panel| active_panel == *agent_panel);
+
+ match event {
+ AgentPanelEvent::ActiveViewChanged => {
+ if is_active_panel {
+ this.focused_thread = agent_panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ this.observe_draft_editor(cx);
+ }
+ this.update_entries(false, cx);
+ }
+ AgentPanelEvent::ThreadFocused => {
+ if is_active_panel {
+ let new_focused = agent_panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ if new_focused.is_some() && new_focused != this.focused_thread {
+ this.focused_thread = new_focused;
+ this.update_entries(false, cx);
+ }
+ }
+ }
+ AgentPanelEvent::BackgroundThreadChanged => {
this.update_entries(false, cx);
}
- }
- AgentPanelEvent::BackgroundThreadChanged => {
- this.update_entries(false, cx);
}
},
)
.detach();
}
+ fn observe_draft_editor(&mut self, cx: &mut Context<Self>) {
+ self._draft_observation = self
+ .multi_workspace
+ .upgrade()
+ .and_then(|mw| {
+ let ws = mw.read(cx).workspace();
+ ws.read(cx).panel::<AgentPanel>(cx)
+ })
+ .and_then(|panel| {
+ let cv = panel.read(cx).active_conversation()?;
+ let tv = cv.read(cx).active_thread()?;
+ Some(tv.read(cx).message_editor.clone())
+ })
+ .map(|editor| {
+ cx.observe(&editor, |_this, _editor, cx| {
+ cx.notify();
+ })
+ });
+ }
+
+ fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
+ let mw = self.multi_workspace.upgrade()?;
+ let workspace = mw.read(cx).workspace();
+ let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let conversation_view = panel.read(cx).active_conversation()?;
+ let thread_view = conversation_view.read(cx).active_thread()?;
+ let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
+ let cleaned = Self::clean_mention_links(&raw);
+ let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
+ if text.is_empty() {
+ None
+ } else {
+ const MAX_CHARS: usize = 250;
+ if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
+ text.truncate(truncate_at);
+ }
+ Some(text.into())
+ }
+ }
+
+ fn clean_mention_links(input: &str) -> String {
+ let mut result = String::with_capacity(input.len());
+ let mut remaining = input;
+
+ while let Some(start) = remaining.find("[@") {
+ result.push_str(&remaining[..start]);
+ let after_bracket = &remaining[start + 1..]; // skip '['
+ if let Some(close_bracket) = after_bracket.find("](") {
+ let mention = &after_bracket[..close_bracket]; // "@something"
+ let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
+ if let Some(close_paren) = after_link_start.find(')') {
+ result.push_str(mention);
+ remaining = &after_link_start[close_paren + 1..];
+ continue;
+ }
+ }
+ // Couldn't parse full link syntax β emit the literal "[@" and move on.
+ result.push_str("[@");
+ remaining = &remaining[start + 2..];
+ }
+ result.push_str(remaining);
+ result
+ }
+
fn all_thread_infos_for_workspace(
workspace: &Entity<Workspace>,
cx: &App,
@@ -486,6 +616,19 @@ impl Sidebar {
let previous = mem::take(&mut self.contents);
+ // Collect the session IDs that were visible before this rebuild so we
+ // can distinguish a thread that was deleted/removed (was in the list,
+ // now gone) from a brand-new thread that hasn't been saved to the
+ // metadata store yet (never was in the list).
+ let previous_session_ids: HashSet<acp::SessionId> = previous
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ ListEntry::Thread(t) => Some(t.session_info.session_id.clone()),
+ _ => None,
+ })
+ .collect();
+
let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
.entries
.iter()
@@ -747,8 +890,6 @@ impl Sidebar {
}
if !query.is_empty() {
- let has_threads = !threads.is_empty();
-
let workspace_highlight_positions =
fuzzy_match_positions(&query, &label).unwrap_or_default();
let workspace_matched = !workspace_highlight_positions.is_empty();
@@ -782,21 +923,11 @@ impl Sidebar {
continue;
}
- if active_entry_index.is_none()
- && self.focused_thread.is_none()
- && active_workspace
- .as_ref()
- .is_some_and(|active| active == workspace)
- {
- active_entry_index = Some(entries.len());
- }
-
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
label,
workspace: workspace.clone(),
highlight_positions: workspace_highlight_positions,
- has_threads,
});
// Track session IDs and compute active_entry_index as we add
@@ -813,30 +944,22 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
- let has_threads = !threads.is_empty();
-
- // Check if this header is the active entry before pushing it.
- if active_entry_index.is_none()
- && self.focused_thread.is_none()
- && active_workspace
- .as_ref()
- .is_some_and(|active| active == workspace)
- {
- active_entry_index = Some(entries.len());
- }
-
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
label,
workspace: workspace.clone(),
highlight_positions: Vec::new(),
- has_threads,
});
if is_collapsed {
continue;
}
+ entries.push(ListEntry::NewThread {
+ path_list: path_list.clone(),
+ workspace: workspace.clone(),
+ });
+
let total = threads.len();
let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
@@ -866,13 +989,6 @@ impl Sidebar {
is_fully_expanded,
});
}
-
- if total == 0 {
- entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: workspace.clone(),
- });
- }
}
}
@@ -886,6 +1002,22 @@ impl Sidebar {
.filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i))
.collect();
+ // If focused_thread points to a thread that was previously in the
+ // list but is now gone (deleted, or its workspace was removed), clear
+ // it. We don't try to redirect to a thread in a different project
+ // group β the delete_thread method already handles within-group
+ // neighbor selection. If it was never in the list it's a brand-new
+ // thread that hasn't been saved to the metadata store yet β leave
+ // things alone and wait for the next rebuild.
+ let focused_thread_was_known = self
+ .focused_thread
+ .as_ref()
+ .is_some_and(|id| previous_session_ids.contains(id));
+
+ if focused_thread_was_known && active_entry_index.is_none() {
+ self.focused_thread = None;
+ }
+
self.active_entry_index = active_entry_index;
self.contents = SidebarContents {
entries,
@@ -969,7 +1101,6 @@ impl Sidebar {
label,
workspace,
highlight_positions,
- has_threads,
} => self.render_project_header(
ix,
false,
@@ -977,7 +1108,6 @@ impl Sidebar {
label,
workspace,
highlight_positions,
- *has_threads,
is_selected,
cx,
),
@@ -1004,7 +1134,7 @@ impl Sidebar {
v_flex()
.w_full()
.border_t_1()
- .border_color(cx.theme().colors().border_variant)
+ .border_color(cx.theme().colors().border.opacity(0.5))
.child(rendered)
.into_any_element()
} else {
@@ -1020,14 +1150,12 @@ impl Sidebar {
label: &SharedString,
workspace: &Entity<Workspace>,
highlight_positions: &[usize],
- has_threads: bool,
is_selected: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let id_prefix = if is_sticky { "sticky-" } else { "" };
let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
- let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}"));
let is_collapsed = self.collapsed_groups.contains(path_list);
let disclosure_icon = if is_collapsed {
@@ -1035,7 +1163,6 @@ impl Sidebar {
} else {
IconName::ChevronDown
};
- let workspace_for_new_thread = workspace.clone();
let workspace_for_remove = workspace.clone();
let path_list_for_toggle = path_list.clone();
@@ -1046,10 +1173,6 @@ impl Sidebar {
let workspace_count = multi_workspace
.as_ref()
.map_or(0, |mw| mw.read(cx).workspaces().len());
- let is_active_workspace = self.focused_thread.is_none()
- && multi_workspace
- .as_ref()
- .is_some_and(|mw| mw.read(cx).workspace() == workspace);
let label = if highlight_positions.is_empty() {
Label::new(label.clone())
@@ -1065,7 +1188,6 @@ impl Sidebar {
ListItem::new(id)
.group_name(group_name)
- .toggle_state(is_active_workspace)
.focused(is_selected)
.child(
h_flex()
@@ -1123,18 +1245,6 @@ impl Sidebar {
}
})),
)
- })
- .when(has_threads, |this| {
- this.child(
- IconButton::new(ib_id, IconName::NewThread)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text("New Thread"))
- .on_click(cx.listener(move |this, _, window, cx| {
- this.selection = None;
- this.create_new_thread(&workspace_for_new_thread, window, cx);
- })),
- )
}),
)
.on_click(cx.listener(move |this, _, window, cx| {
@@ -1175,7 +1285,6 @@ impl Sidebar {
label,
workspace,
highlight_positions,
- has_threads,
} = self.contents.entries.get(header_idx)?
else {
return None;
@@ -1192,7 +1301,6 @@ impl Sidebar {
&label,
&workspace,
&highlight_positions,
- *has_threads,
is_selected,
cx,
);
@@ -1211,15 +1319,21 @@ impl Sidebar {
})
.unwrap_or(px(0.));
+ let color = cx.theme().colors();
+ let background = color
+ .title_bar_background
+ .blend(color.panel_background.opacity(0.8));
+
let element = v_flex()
.absolute()
.top(top_offset)
.left_0()
.w_full()
- .bg(cx.theme().colors().surface_background)
+ .bg(background)
.border_b_1()
- .border_color(cx.theme().colors().border_variant)
+ .border_color(color.border.opacity(0.5))
.child(header_element)
+ .shadow_xs()
.into_any_element();
Some(element)
@@ -1314,44 +1428,6 @@ impl Sidebar {
});
}
- fn close_all_projects(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let Some(multi_workspace) = self.multi_workspace.upgrade() else {
- return;
- };
-
- let workspace_count = multi_workspace.read(cx).workspaces().len();
- let active_index = multi_workspace.read(cx).active_workspace_index();
-
- // Remove all workspaces except the active one, iterating in reverse
- // so that indices of not-yet-visited workspaces remain valid.
- for index in (0..workspace_count).rev() {
- if index != active_index {
- multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.remove_workspace(index, window, cx);
- });
- }
- }
-
- // Remove all worktrees from the remaining workspace so it becomes empty.
- let workspace = multi_workspace.read(cx).workspace().clone();
- let worktree_ids: Vec<_> = workspace
- .read(cx)
- .project()
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).id())
- .collect();
-
- workspace.update(cx, |workspace, cx| {
- let project = workspace.project().clone();
- project.update(cx, |project, cx| {
- for worktree_id in worktree_ids {
- project.remove_worktree(worktree_id, cx);
- }
- });
- });
- }
-
fn toggle_collapse(
&mut self,
path_list: &PathList,
@@ -1664,7 +1740,116 @@ impl Sidebar {
}
}
- fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+ fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+ for workspace in workspaces {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ let cancelled =
+ agent_panel.update(cx, |panel, cx| panel.cancel_thread(session_id, cx));
+ if cancelled {
+ return;
+ }
+ }
+ }
+ }
+
+ fn delete_thread(
+ &mut self,
+ session_id: &acp::SessionId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ // If we're deleting the currently focused thread, move focus to the
+ // nearest thread within the same project group. We never cross group
+ // boundaries β if the group has no other threads, clear focus and open
+ // a blank new thread in the panel instead.
+ if self.focused_thread.as_ref() == Some(session_id) {
+ let current_pos = self.contents.entries.iter().position(|entry| {
+ matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
+ });
+
+ // Find the workspace that owns this thread's project group by
+ // walking backwards to the nearest ProjectHeader. We must use
+ // *this* workspace (not the active workspace) because the user
+ // might be deleting a thread in a non-active group.
+ let group_workspace = current_pos.and_then(|pos| {
+ self.contents.entries[..pos]
+ .iter()
+ .rev()
+ .find_map(|e| match e {
+ ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
+ _ => None,
+ })
+ });
+
+ let next_thread = current_pos.and_then(|pos| {
+ let group_start = self.contents.entries[..pos]
+ .iter()
+ .rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+ .map_or(0, |i| i + 1);
+ let group_end = self.contents.entries[pos + 1..]
+ .iter()
+ .position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
+ .map_or(self.contents.entries.len(), |i| pos + 1 + i);
+
+ let above = self.contents.entries[group_start..pos]
+ .iter()
+ .rev()
+ .find_map(|entry| {
+ if let ListEntry::Thread(t) = entry {
+ Some(t)
+ } else {
+ None
+ }
+ });
+
+ above.or_else(|| {
+ self.contents.entries[pos + 1..group_end]
+ .iter()
+ .find_map(|entry| {
+ if let ListEntry::Thread(t) = entry {
+ Some(t)
+ } else {
+ None
+ }
+ })
+ })
+ });
+
+ if let Some(next) = next_thread {
+ self.focused_thread = Some(next.session_info.session_id.clone());
+
+ if let Some(workspace) = &group_workspace {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ agent_panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(
+ next.agent.clone(),
+ next.session_info.session_id.clone(),
+ next.session_info.work_dirs.clone(),
+ next.session_info.title.clone(),
+ true,
+ window,
+ cx,
+ );
+ });
+ }
+ }
+ } else {
+ self.focused_thread = None;
+ if let Some(workspace) = &group_workspace {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ agent_panel.update(cx, |panel, cx| {
+ panel.new_thread(&NewThread, window, cx);
+ });
+ }
+ }
+ }
+ }
+
let Some(thread_store) = ThreadStore::try_global(cx) else {
return;
};
@@ -1682,7 +1867,7 @@ impl Sidebar {
fn remove_selected_thread(
&mut self,
_: &RemoveSelectedThread,
- _window: &mut Window,
+ window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.selection else {
@@ -1695,7 +1880,7 @@ impl Sidebar {
return;
}
let session_id = thread.session_info.session_id.clone();
- self.delete_thread(&session_id, cx);
+ self.delete_thread(&session_id, window, cx);
}
fn render_thread(
@@ -1719,6 +1904,10 @@ impl Sidebar {
let is_hovered = self.hovered_thread_index == Some(ix);
let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id);
+ let is_running = matches!(
+ thread.status,
+ AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
+ );
let can_delete = thread.agent == Agent::NativeAgent;
let session_id_for_delete = thread.session_info.session_id.clone();
let focus_handle = self.focus_handle.clone();
@@ -1781,7 +1970,22 @@ impl Sidebar {
}
cx.notify();
}))
- .when(is_hovered && can_delete, |this| {
+ .when(is_hovered && is_running, |this| {
+ this.action_slot(
+ IconButton::new("stop-thread", IconName::Stop)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .style(ButtonStyle::Tinted(TintColor::Error))
+ .tooltip(Tooltip::text("Stop Generation"))
+ .on_click({
+ let session_id = session_id_for_delete.clone();
+ cx.listener(move |this, _, _window, cx| {
+ this.stop_thread(&session_id, cx);
+ })
+ }),
+ )
+ })
+ .when(is_hovered && can_delete && !is_running, |this| {
this.action_slot(
IconButton::new("delete-thread", IconName::Trash)
.icon_size(IconSize::Small)
@@ -1799,9 +2003,8 @@ impl Sidebar {
})
.on_click({
let session_id = session_id_for_delete.clone();
- cx.listener(move |this, _, _window, cx| {
- this.delete_thread(&session_id, cx);
- cx.stop_propagation();
+ cx.listener(move |this, _, window, cx| {
+ this.delete_thread(&session_id, window, cx);
})
}),
)
@@ -1839,6 +2042,44 @@ impl Sidebar {
self.filter_editor.clone()
}
+ fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ let workspace = self
+ .multi_workspace
+ .upgrade()
+ .map(|mw| mw.read(cx).workspace().downgrade());
+
+ let focus_handle = workspace
+ .as_ref()
+ .and_then(|ws| ws.upgrade())
+ .map(|w| w.read(cx).focus_handle(cx))
+ .unwrap_or_else(|| cx.focus_handle());
+
+ let popover_handle = self.recent_projects_popover_handle.clone();
+
+ PopoverMenu::new("sidebar-recent-projects-menu")
+ .with_handle(popover_handle)
+ .menu(move |window, cx| {
+ workspace.as_ref().map(|ws| {
+ RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
+ })
+ })
+ .trigger_with_tooltip(
+ IconButton::new("open-project", IconName::OpenFolder)
+ .icon_size(IconSize::Small)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
+ |_window, cx| {
+ Tooltip::for_action(
+ "Recent Projects",
+ &OpenRecent {
+ create_new_window: false,
+ },
+ cx,
+ )
+ },
+ )
+ .anchor(gpui::Corner::TopLeft)
+ }
+
fn render_view_more(
&self,
ix: usize,
@@ -1851,27 +2092,24 @@ impl Sidebar {
let path_list = path_list.clone();
let id = SharedString::from(format!("view-more-{}", ix));
- let (icon, label) = if is_fully_expanded {
- (IconName::ListCollapse, "Collapse")
+ let icon = if is_fully_expanded {
+ IconName::ListCollapse
} else {
- (IconName::Plus, "View More")
+ IconName::Plus
};
- ListItem::new(id)
+ let label: SharedString = if is_fully_expanded {
+ "Collapse".into()
+ } else if remaining_count > 0 {
+ format!("View More ({})", remaining_count).into()
+ } else {
+ "View More".into()
+ };
+
+ ThreadItem::new(id, label)
+ .icon(icon)
.focused(is_selected)
- .child(
- h_flex()
- .py_1()
- .gap_1p5()
- .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
- .child(Label::new(label).color(Color::Muted))
- .when(!is_fully_expanded, |this| {
- this.child(
- Label::new(format!("({})", remaining_count))
- .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
- )
- }),
- )
+ .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85)))
.on_click(cx.listener(move |this, _, _window, cx| {
this.selection = None;
if is_fully_expanded {
@@ -1885,6 +2123,39 @@ impl Sidebar {
.into_any_element()
}
+ fn new_thread_in_group(
+ &mut self,
+ _: &NewThreadInGroup,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ // If there is a keyboard selection, walk backwards through
+ // `project_header_indices` to find the header that owns the selected
+ // row. Otherwise fall back to the active workspace.
+ let workspace = if let Some(selected_ix) = self.selection {
+ self.contents
+ .project_header_indices
+ .iter()
+ .rev()
+ .find(|&&header_ix| header_ix <= selected_ix)
+ .and_then(|&header_ix| match &self.contents.entries[header_ix] {
+ ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
+ _ => None,
+ })
+ } else {
+ // Use the currently active workspace.
+ self.multi_workspace
+ .upgrade()
+ .map(|mw| mw.read(cx).workspace().clone())
+ };
+
+ let Some(workspace) = workspace else {
+ return;
+ };
+
+ self.create_new_thread(&workspace, window, cx);
+ }
+
fn create_new_thread(
&mut self,
workspace: &Entity<Workspace>,
@@ -1895,6 +2166,12 @@ impl Sidebar {
return;
};
+ // Clear focused_thread immediately so no existing thread stays
+ // highlighted while the new blank thread is being shown. Without this,
+ // if the target workspace is already active (so ActiveWorkspaceChanged
+ // never fires), the previous thread's highlight would linger.
+ self.focused_thread = None;
+
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), cx);
});
@@ -1917,126 +2194,126 @@ impl Sidebar {
is_selected: bool,
cx: &mut Context<Self>,
) -> AnyElement {
+ let is_active = self.active_entry_index.is_none()
+ && self
+ .multi_workspace
+ .upgrade()
+ .map_or(false, |mw| mw.read(cx).workspace() == workspace);
+
+ let label: SharedString = if is_active {
+ self.active_draft_text(cx)
+ .unwrap_or_else(|| "New Thread".into())
+ } else {
+ "New Thread".into()
+ };
+
let workspace = workspace.clone();
+ let id = SharedString::from(format!("new-thread-btn-{}", ix));
- div()
- .w_full()
- .p_2()
- .pt_1p5()
- .child(
- Button::new(
- SharedString::from(format!("new-thread-btn-{}", ix)),
- "New Thread",
- )
- .full_width()
- .style(ButtonStyle::Outlined)
- .start_icon(
- Icon::new(IconName::Plus)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .toggle_state(is_selected)
- .on_click(cx.listener(move |this, _, window, cx| {
- this.selection = None;
- this.create_new_thread(&workspace, window, cx);
- })),
- )
+ ThreadItem::new(id, label)
+ .icon(IconName::Plus)
+ .selected(is_active)
+ .focused(is_selected)
+ .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85)))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.selection = None;
+ this.create_new_thread(&workspace, window, cx);
+ }))
.into_any_element()
}
- fn render_thread_list_header(
- &self,
- window: &Window,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
+ fn render_sidebar_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_query = self.has_filter_query(cx);
- let needs_traffic_light_padding = cfg!(target_os = "macos") && !window.is_fullscreen();
- let has_open_projects = self
- .multi_workspace
- .upgrade()
- .map(|mw| {
- let mw = mw.read(cx);
- mw.workspaces().len() > 1
- || mw
- .workspace()
- .read(cx)
- .project()
- .read(cx)
- .visible_worktrees(cx)
- .next()
- .is_some()
- })
- .unwrap_or(false);
+ let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+ let header_height = platform_title_bar_height(window);
v_flex()
- .flex_none()
.child(
h_flex()
- .h(Tab::container_height(cx) - px(1.))
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .when(needs_traffic_light_padding, |this| {
+ .h(header_height)
+ .mt_px()
+ .pb_px()
+ .when(traffic_lights, |this| {
this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
})
- .child(self.render_sidebar_toggle_button(cx)),
- )
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .gap_1p5()
- .px_1p5()
+ .pr_1p5()
.border_b_1()
.border_color(cx.theme().colors().border)
- .child(self.render_filter_input())
+ .justify_between()
+ .child(self.render_sidebar_toggle_button(cx))
.child(
h_flex()
.gap_0p5()
- .when(has_query, |this| {
- this.child(
- IconButton::new("clear_filter", IconName::Close)
- .shape(IconButtonShape::Square)
- .tooltip(Tooltip::text("Clear Search"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.reset_filter_editor_text(window, cx);
- this.update_entries(false, cx);
- })),
- )
- })
- .when(has_open_projects, |this| {
- this.child(
- IconButton::new("close-all-projects", IconName::Exit)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Close All Projects"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.close_all_projects(window, cx);
- })),
- )
- })
.child(
IconButton::new("archive", IconName::Archive)
.icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Archive"))
+ .tooltip(Tooltip::text("View Archived Threads"))
.on_click(cx.listener(|this, _, window, cx| {
this.show_archive(window, cx);
})),
- ),
+ )
+ .child(self.render_recent_projects_button(cx)),
),
)
+ .child(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .px_1p5()
+ .gap_1p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ h_flex().size_4().flex_none().justify_center().child(
+ Icon::new(IconName::MagnifyingGlass)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(self.render_filter_input())
+ .when(has_query, |this| {
+ this.child(
+ IconButton::new("clear_filter", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Clear Search"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.reset_filter_editor_text(window, cx);
+ this.update_entries(false, cx);
+ })),
+ )
+ }),
+ )
}
fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
let icon = IconName::ThreadsSidebarLeftOpen;
- h_flex().h_full().child(
- IconButton::new("sidebar-close-toggle", icon)
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
- })
- .on_click(|_, window, cx| {
- window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
- }),
- )
+ IconButton::new("sidebar-close-toggle", icon)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::element(move |_window, cx| {
+ v_flex()
+ .gap_1()
+ .child(
+ h_flex()
+ .gap_2()
+ .justify_between()
+ .child(Label::new("Toggle Sidebar"))
+ .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
+ )
+ .child(
+ h_flex()
+ .pt_1()
+ .gap_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .justify_between()
+ .child(Label::new("Focus Sidebar"))
+ .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
+ )
+ .into_any_element()
+ }))
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+ })
}
}
@@ -41,8 +41,8 @@ use std::sync::Arc;
use theme::ActiveTheme;
use title_bar_settings::TitleBarSettings;
use ui::{
- Avatar, ButtonLike, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle,
- TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
+ Avatar, ButtonLike, ContextMenu, Divider, IconWithIndicator, Indicator, PopoverMenu,
+ PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
};
use update_version::UpdateVersion;
use util::ResultExt;
@@ -169,6 +169,7 @@ impl Render for TitleBar {
children.push(
h_flex()
+ .h_full()
.gap_0p5()
.map(|title_bar| {
let mut render_project_items = title_bar_settings.show_branch_name
@@ -705,23 +706,29 @@ impl TitleBar {
let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
Some(
- IconButton::new(
- "toggle-workspace-sidebar",
- IconName::ThreadsSidebarLeftClosed,
- )
- .icon_size(IconSize::Small)
- .when(has_notifications, |button| {
- button
- .indicator(Indicator::dot().color(Color::Accent))
- .indicator_border_color(Some(cx.theme().colors().title_bar_background))
- })
- .tooltip(move |_, cx| {
- Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
- })
- .on_click(|_, window, cx| {
- window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
- })
- .into_any_element(),
+ h_flex()
+ .h_full()
+ .gap_0p5()
+ .child(
+ IconButton::new(
+ "toggle-workspace-sidebar",
+ IconName::ThreadsSidebarLeftClosed,
+ )
+ .icon_size(IconSize::Small)
+ .when(has_notifications, |button| {
+ button
+ .indicator(Indicator::dot().color(Color::Accent))
+ .indicator_border_color(Some(cx.theme().colors().title_bar_background))
+ })
+ .tooltip(move |_, cx| {
+ Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+ }),
+ )
+ .child(Divider::vertical().color(ui::DividerColor::Border))
+ .into_any_element(),
)
}
@@ -741,6 +748,14 @@ impl TitleBar {
"Open Recent Project".to_string()
};
+ let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
+
+ if is_sidebar_open {
+ return self
+ .render_project_name_with_sidebar_popover(display_name, is_project_selected, cx)
+ .into_any_element();
+ }
+
let focus_handle = workspace
.upgrade()
.map(|w| w.read(cx).focus_handle(cx))
@@ -782,6 +797,53 @@ impl TitleBar {
.into_any_element()
}
+ /// When the sidebar is open, the title bar's project name button becomes a
+ /// plain button that toggles the sidebar's popover (so the popover is always
+ /// anchored to the sidebar). Both buttons show their selected state together.
+ fn render_project_name_with_sidebar_popover(
+ &self,
+ display_name: String,
+ is_project_selected: bool,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ let multi_workspace = self.multi_workspace.clone();
+
+ let is_popover_deployed = multi_workspace
+ .as_ref()
+ .and_then(|mw| mw.upgrade())
+ .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx))
+ .unwrap_or(false);
+
+ Button::new("project_name_trigger", display_name)
+ .label_size(LabelSize::Small)
+ .when(self.worktree_count(cx) > 1, |this| {
+ this.end_icon(
+ Icon::new(IconName::ChevronDown)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ })
+ .toggle_state(is_popover_deployed)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .when(!is_project_selected, |s| s.color(Color::Muted))
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action(
+ "Recent Projects",
+ &zed_actions::OpenRecent {
+ create_new_window: false,
+ },
+ cx,
+ )
+ })
+ .on_click(move |_, window, cx| {
+ if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) {
+ mw.update(cx, |mw, cx| {
+ mw.toggle_recent_projects_popover(window, cx);
+ });
+ }
+ })
+ }
+
pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let effective_worktree = self.effective_active_worktree(cx)?;
let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;
@@ -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::*;
@@ -3,7 +3,10 @@ use crate::{
IconDecorationKind, prelude::*,
};
-use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between};
+use gpui::{
+ Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
+ pulsating_between,
+};
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -36,6 +39,7 @@ pub struct ThreadItem {
worktree_highlight_positions: Vec<usize>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+ title_label_color: Option<Color>,
action_slot: Option<AnyElement>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
}
@@ -62,6 +66,7 @@ impl ThreadItem {
worktree_highlight_positions: Vec::new(),
on_click: None,
on_hover: Box::new(|_, _, _| {}),
+ title_label_color: None,
action_slot: None,
tooltip: None,
}
@@ -155,6 +160,11 @@ impl ThreadItem {
self
}
+ pub fn title_label_color(mut self, color: Color) -> Self {
+ self.title_label_color = Some(color);
+ self
+ }
+
pub fn action_slot(mut self, element: impl IntoElement) -> Self {
self.action_slot = Some(element.into_any_element());
self
@@ -230,7 +240,7 @@ impl RenderOnce for ThreadItem {
let title = self.title;
let highlight_positions = self.highlight_positions;
let title_label = if self.generating_title {
- Label::new("New Threadβ¦")
+ Label::new(title)
.color(Color::Muted)
.with_animation(
"generating-title",
@@ -241,15 +251,31 @@ impl RenderOnce for ThreadItem {
)
.into_any_element()
} else if highlight_positions.is_empty() {
- Label::new(title).into_any_element()
+ let label = Label::new(title);
+ let label = if let Some(color) = self.title_label_color {
+ label.color(color)
+ } else {
+ label
+ };
+ label.into_any_element()
} else {
- HighlightedLabel::new(title, highlight_positions).into_any_element()
+ let label = HighlightedLabel::new(title, highlight_positions);
+ let label = if let Some(color) = self.title_label_color {
+ label.color(color)
+ } else {
+ label
+ };
+ label.into_any_element()
};
+ let b_bg = color
+ .title_bar_background
+ .blend(color.panel_background.opacity(0.8));
+
let base_bg = if self.selected {
color.element_active
} else {
- color.panel_background
+ b_bg
};
let gradient_overlay =
@@ -314,7 +340,15 @@ impl RenderOnce for ThreadItem {
.gradient_stop(0.75)
.group_name("thread-item");
- this.child(h_flex().relative().child(overlay).child(slot))
+ this.child(
+ h_flex()
+ .relative()
+ .on_mouse_down(MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation()
+ })
+ .child(overlay)
+ .child(slot),
+ )
})
}),
)
@@ -1,177 +0,0 @@
-use gpui::{AnyView, ClickEvent};
-use ui_macros::RegisterComponent;
-
-use crate::prelude::*;
-use crate::{IconButton, IconName, Tooltip};
-
-#[derive(IntoElement, RegisterComponent)]
-pub struct ThreadSidebarToggle {
- sidebar_selected: bool,
- thread_selected: bool,
- flipped: bool,
- sidebar_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
- thread_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
- on_sidebar_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
- on_thread_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
-}
-
-impl ThreadSidebarToggle {
- pub fn new() -> Self {
- Self {
- sidebar_selected: false,
- thread_selected: false,
- flipped: false,
- sidebar_tooltip: None,
- thread_tooltip: None,
- on_sidebar_click: None,
- on_thread_click: None,
- }
- }
-
- pub fn sidebar_selected(mut self, selected: bool) -> Self {
- self.sidebar_selected = selected;
- self
- }
-
- pub fn thread_selected(mut self, selected: bool) -> Self {
- self.thread_selected = selected;
- self
- }
-
- pub fn flipped(mut self, flipped: bool) -> Self {
- self.flipped = flipped;
- self
- }
-
- pub fn sidebar_tooltip(
- mut self,
- tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
- ) -> Self {
- self.sidebar_tooltip = Some(Box::new(tooltip));
- self
- }
-
- pub fn thread_tooltip(
- mut self,
- tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
- ) -> Self {
- self.thread_tooltip = Some(Box::new(tooltip));
- self
- }
-
- pub fn on_sidebar_click(
- mut self,
- handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- ) -> Self {
- self.on_sidebar_click = Some(Box::new(handler));
- self
- }
-
- pub fn on_thread_click(
- mut self,
- handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- ) -> Self {
- self.on_thread_click = Some(Box::new(handler));
- self
- }
-}
-
-impl RenderOnce for ThreadSidebarToggle {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- let sidebar_icon = match (self.sidebar_selected, self.flipped) {
- (true, false) => IconName::ThreadsSidebarLeftOpen,
- (false, false) => IconName::ThreadsSidebarLeftClosed,
- (true, true) => IconName::ThreadsSidebarRightOpen,
- (false, true) => IconName::ThreadsSidebarRightClosed,
- };
-
- h_flex()
- .min_w_0()
- .rounded_sm()
- .gap_px()
- .border_1()
- .border_color(cx.theme().colors().border)
- .when(self.flipped, |this| this.flex_row_reverse())
- .child(
- IconButton::new("sidebar-toggle", sidebar_icon)
- .icon_size(IconSize::Small)
- .toggle_state(self.sidebar_selected)
- .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip))
- .when_some(self.on_sidebar_click, |this, handler| {
- this.on_click(handler)
- }),
- )
- .child(div().h_4().w_px().bg(cx.theme().colors().border))
- .child(
- IconButton::new("thread-toggle", IconName::Thread)
- .icon_size(IconSize::Small)
- .toggle_state(self.thread_selected)
- .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip))
- .when_some(self.on_thread_click, |this, handler| this.on_click(handler)),
- )
- }
-}
-
-impl Component for ThreadSidebarToggle {
- fn scope() -> ComponentScope {
- ComponentScope::Agent
- }
-
- fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
- let container = || div().p_2().bg(cx.theme().colors().status_bar_background);
-
- let examples = vec![
- single_example(
- "Both Unselected",
- container()
- .child(ThreadSidebarToggle::new())
- .into_any_element(),
- ),
- single_example(
- "Sidebar Selected",
- container()
- .child(ThreadSidebarToggle::new().sidebar_selected(true))
- .into_any_element(),
- ),
- single_example(
- "Thread Selected",
- container()
- .child(ThreadSidebarToggle::new().thread_selected(true))
- .into_any_element(),
- ),
- single_example(
- "Both Selected",
- container()
- .child(
- ThreadSidebarToggle::new()
- .sidebar_selected(true)
- .thread_selected(true),
- )
- .into_any_element(),
- ),
- single_example(
- "Flipped",
- container()
- .child(
- ThreadSidebarToggle::new()
- .sidebar_selected(true)
- .thread_selected(true)
- .flipped(true),
- )
- .into_any_element(),
- ),
- single_example(
- "With Tooltips",
- container()
- .child(
- ThreadSidebarToggle::new()
- .sidebar_tooltip(Tooltip::text("Toggle Sidebar"))
- .thread_tooltip(Tooltip::text("Toggle Thread")),
- )
- .into_any_element(),
- ),
- ];
-
- Some(example_group(examples).into_any_element())
- }
-}
@@ -45,6 +45,8 @@ pub trait Sidebar: Focusable + Render + Sized {
fn width(&self, cx: &App) -> Pixels;
fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
fn has_notifications(&self, cx: &App) -> bool;
+ fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
+ fn is_recent_projects_popover_deployed(&self) -> bool;
}
pub trait SidebarHandle: 'static + Send + Sync {
@@ -55,6 +57,8 @@ pub trait SidebarHandle: 'static + Send + Sync {
fn has_notifications(&self, cx: &App) -> bool;
fn to_any(&self) -> AnyView;
fn entity_id(&self) -> EntityId;
+ fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
+ fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool;
}
#[derive(Clone)]
@@ -95,6 +99,16 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
fn entity_id(&self) -> EntityId {
Entity::entity_id(self)
}
+
+ fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
+ self.update(cx, |this, cx| {
+ this.toggle_recent_projects_popover(window, cx);
+ });
+ }
+
+ fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
+ self.read(cx).is_recent_projects_popover_deployed()
+ }
}
pub struct MultiWorkspace {
@@ -167,6 +181,18 @@ impl MultiWorkspace {
.map_or(false, |s| s.has_notifications(cx))
}
+ pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
+ if let Some(sidebar) = &self.sidebar {
+ sidebar.toggle_recent_projects_popover(window, cx);
+ }
+ }
+
+ pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
+ self.sidebar
+ .as_ref()
+ .map_or(false, |s| s.is_recent_projects_popover_deployed(cx))
+ }
+
pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
}