From e79429b51b626e0120278b694351cd34a83386be Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:14:25 -0300 Subject: [PATCH] agent_ui: Add more UI refinements to sidebar (#51545) - Move archive button to the header for simplicity - Hook up the delete button in the archive view - Improve how titles are displayed before summary is generated - Hook up keybinding for deleting threads in both the sidebar and archive view Release Notes: - N/A --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- assets/keymaps/default-windows.json | 3 +- crates/acp_thread/src/acp_thread.rs | 4 + crates/agent/src/thread.rs | 8 ++ crates/agent_ui/src/agent_panel.rs | 59 +++++----- crates/agent_ui/src/sidebar.rs | 115 +++++++++++++------- crates/agent_ui/src/threads_archive_view.rs | 89 ++++++++++++++- crates/ui/src/components/ai/thread_item.rs | 25 ++++- 9 files changed, 230 insertions(+), 79 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 56a51843ca9da052e39450ba38d8afcda9d1166d..a79384ad0139b804f0ba7721e6f42260733c8e0c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -671,13 +671,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a4aec7cfe8053f3f23b43652f7e58f319c9691f6..14804998a08de962b1849d7b1a728d1d9d6f9778 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -739,13 +739,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "cmd-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index c10054d5813c6deae33b7a790b3639e7f2c802aa..66896f43984dac73d7c098bfb46fb1a19568c14a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -675,13 +675,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 7b6c198e5d77f6f962c6d259929d065feb76e48d..99fe83a5c6f74c1989e2b5e2317d7c267d531eef 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1207,6 +1207,10 @@ impl AcpThread { .unwrap_or_else(|| self.title.clone()) } + pub fn has_provisional_title(&self) -> bool { + self.provisional_title.is_some() + } + pub fn entries(&self) -> &[AgentThreadEntry] { &self.entries } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 02ffac47f120ee3ec4694b3a3be085af053c5909..55fdace2cfea1dd77be507cb06f0a9d4b6634cf7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2570,6 +2570,14 @@ impl Thread { .is_some() { _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); + } else { + // Emit TitleUpdated even on failure so that the propagation + // chain (agent::Thread → NativeAgent → AcpThread) fires and + // clears any provisional title that was set before the turn. + _ = this.update(cx, |_, cx| { + cx.emit(TitleUpdated); + cx.notify(); + }); } _ = this.update(cx, |this, _| this.pending_title_generation = None); })); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d5c2942cf3528b94ad7d93271ef75e976bcbea56..10d24e61fe3e6bbf5d0a0d88e0f28ba3fbfa2b78 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3266,48 +3266,49 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::AgentThread { server_view } => { - let is_generating_title = server_view - .read(cx) - .as_native_thread(cx) - .map_or(false, |t| t.read(cx).is_generating_title()); + let server_view_ref = server_view.read(cx); + let is_generating_title = server_view_ref.as_native_thread(cx).is_some() + && server_view_ref.parent_thread(cx).map_or(false, |tv| { + tv.read(cx).thread.read(cx).has_provisional_title() + }); - if let Some(title_editor) = server_view - .read(cx) + if let Some(title_editor) = server_view_ref .parent_thread(cx) .map(|r| r.read(cx).title_editor.clone()) { - let container = div() - .w_full() - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &menu::Confirm, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &editor::actions::Cancel, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .child(title_editor); - if is_generating_title { - container + Label::new("New Thread…") + .color(Color::Muted) + .truncate() .with_animation( "generating_title", Animation::new(Duration::from_secs(2)) .repeat() .with_easing(pulsating_between(0.4, 0.8)), - |div, delta| div.opacity(delta), + |label, delta| label.alpha(delta), ) .into_any_element() } else { - container.into_any_element() + div() + .w_full() + .on_action({ + let thread_view = server_view.downgrade(); + move |_: &menu::Confirm, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window, cx); + } + } + }) + .on_action({ + let thread_view = server_view.downgrade(); + move |_: &editor::actions::Cancel, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window, cx); + } + } + }) + .child(title_editor) + .into_any_element() } } else { Label::new(server_view.read(cx).title(cx)) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2586e3691278095ee177745e13c01b6e3d531145..333146bd7ac43f7a9c3851de1f4a7e6176609368 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,5 @@ use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; -use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread}; +use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; @@ -83,6 +83,7 @@ struct ActiveThreadInfo { icon: IconName, icon_from_external_svg: Option, is_background: bool, + is_title_generating: bool, diff_stats: DiffStats, } @@ -115,6 +116,7 @@ struct ThreadEntry { workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, + is_title_generating: bool, highlight_positions: Vec, worktree_name: Option, worktree_highlight_positions: Vec, @@ -453,6 +455,8 @@ impl Sidebar { let icon = thread_view_ref.agent_icon; let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); let title = thread.title(); + let is_native = thread_view_ref.as_native_thread(cx).is_some(); + let is_title_generating = is_native && thread.has_provisional_title(); let session_id = thread.session_id().clone(); let is_background = agent_panel_ref.is_background_thread(&session_id); @@ -476,6 +480,7 @@ impl Sidebar { icon, icon_from_external_svg, is_background, + is_title_generating, diff_stats, } }) @@ -593,6 +598,7 @@ impl Sidebar { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -646,6 +652,7 @@ impl Sidebar { workspace: target_workspace.clone(), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: Some(worktree_name.clone()), worktree_highlight_positions: Vec::new(), @@ -676,6 +683,7 @@ impl Sidebar { thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; + thread.is_title_generating = info.is_title_generating; thread.diff_stats = info.diff_stats; } } @@ -1030,6 +1038,7 @@ impl Sidebar { .end_hover_gradient_overlay(true) .end_hover_slot( h_flex() + .gap_1() .when(workspace_count > 1, |this| { this.child( IconButton::new( @@ -1588,7 +1597,6 @@ impl Sidebar { let Some(thread_store) = ThreadStore::try_global(cx) else { return; }; - self.hovered_thread_index = None; thread_store.update(cx, |store, cx| { store .delete_thread(session_id.clone(), cx) @@ -1596,6 +1604,25 @@ impl Sidebar { }); } + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + return; + }; + if thread.agent != Agent::NativeAgent { + return; + } + let session_id = thread.session_info.session_id.clone(); + self.delete_thread(&session_id, cx); + } + fn render_thread( &self, ix: usize, @@ -1620,6 +1647,7 @@ impl Sidebar { let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id); 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(); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -1660,6 +1688,7 @@ impl Sidebar { .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) + .generating_title(thread.is_title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) @@ -1679,16 +1708,27 @@ impl Sidebar { } cx.notify(); })) - .when((is_hovered || is_selected) && can_delete, |this| { + .when(is_hovered && can_delete, |this| { this.action_slot( IconButton::new("delete-thread", IconName::Trash) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .tooltip(Tooltip::text("Delete Thread")) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) .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(); }) }), ) @@ -1848,17 +1888,30 @@ impl Sidebar { this.child(self.render_sidebar_toggle_button(false, cx)) }) .child(self.render_filter_input()) - .when(has_query, |this| { - this.when(!docked_right, |this| this.pr_1p5()).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(cx); - })), - ) - }) + .child( + h_flex() + .gap_0p5() + .when(!docked_right, |this| this.pr_1p5()) + .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(cx); + })), + ) + }) + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Archive")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + })), + ), + ) .when(docked_right, |this| { this.pl_2() .pr_0p5() @@ -1866,27 +1919,6 @@ impl Sidebar { }) } - fn render_thread_list_footer(&self, cx: &mut Context) -> impl IntoElement { - h_flex() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("view-archive", "Archive") - .full_width() - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Archive) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.show_archive(window, cx); - })), - ) - } - fn render_sidebar_toggle_button( &self, docked_right: bool, @@ -2064,7 +2096,7 @@ impl Render for Sidebar { v_flex() .id("workspace-sidebar") - .key_context("WorkspaceSidebar") + .key_context("ThreadsSidebar") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) @@ -2076,6 +2108,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::remove_selected_thread)) .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) @@ -2097,8 +2130,7 @@ impl Render for Sidebar { ) .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), - ) - .child(self.render_thread_list_footer(cx)), + ), SidebarView::Archive => { if let Some(archive_view) = &self.archive_view { this.child(archive_view.clone()) @@ -2641,6 +2673,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2663,6 +2696,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2685,6 +2719,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2707,6 +2742,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2729,6 +2765,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: true, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index e1fd44b4d81280037404fa3f2415b39bdc2aade7..ce5cae4830be732cbc6ca0156d61eb3c48dae888 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,8 +1,12 @@ use std::sync::Arc; -use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory}; +use crate::{ + Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, + thread_history::ThreadHistory, +}; use acp_thread::AgentSessionInfo; use agent::ThreadStore; +use agent_client_protocol as acp; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; @@ -109,6 +113,7 @@ pub struct ThreadsArchiveView { list_state: ListState, items: Vec, selection: Option, + hovered_index: Option, filter_editor: Entity, _subscriptions: Vec, selected_agent_menu: PopoverMenuHandle, @@ -152,6 +157,7 @@ impl ThreadsArchiveView { list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), items: Vec::new(), selection: None, + hovered_index: None, filter_editor, _subscriptions: vec![filter_editor_subscription], selected_agent_menu: PopoverMenuHandle::default(), @@ -272,6 +278,37 @@ impl ThreadsArchiveView { }); } + fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let Some(history) = &self.history else { + return; + }; + if !history.read(cx).supports_delete() { + return; + } + let session_id = session_id.clone(); + history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + let session_id = session.session_id.clone(); + self.delete_thread(&session_id, cx); + } + fn is_selectable_item(&self, ix: usize) -> bool { matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) } @@ -377,9 +414,17 @@ impl ThreadsArchiveView { highlight_positions, } => { let is_selected = self.selection == Some(ix); + let hovered = self.hovered_index == Some(ix); + let supports_delete = self + .history + .as_ref() + .map(|h| h.read(cx).supports_delete()) + .unwrap_or(false); let title: SharedString = session.title.clone().unwrap_or_else(|| "Untitled".into()); let session_info = session.clone(); + let session_id_for_delete = session.session_id.clone(); + let focus_handle = self.focus_handle.clone(); let highlight_positions = highlight_positions.clone(); let timestamp = session.created_at.or(session.updated_at).map(|entry_time| { @@ -429,12 +474,45 @@ impl ThreadsArchiveView { .gap_2() .justify_between() .child(title_label) - .when_some(timestamp, |this, ts| { - this.child( - Label::new(ts).size(LabelSize::Small).color(Color::Muted), - ) + .when(!(hovered && supports_delete), |this| { + this.when_some(timestamp, |this, ts| { + this.child( + Label::new(ts).size(LabelSize::Small).color(Color::Muted), + ) + }) }), ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_thread(&session_id_for_delete, cx); + cx.stop_propagation(); + })), + ) + } else { + None + }) .on_click(cx.listener(move |this, _, window, cx| { this.open_thread(session_info.clone(), window, cx); })) @@ -683,6 +761,7 @@ impl Render for ThreadsArchiveView { .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .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)) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 51ed3d4e01b5dd469a29d3b969a10be2f3d88c12..6ab137227a4699e38a90b530a5554e6fe66f1ee5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -3,7 +3,8 @@ use crate::{ IconDecorationKind, prelude::*, }; -use gpui::{AnyView, ClickEvent, Hsla, SharedString}; +use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between}; +use std::time::Duration; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -23,6 +24,7 @@ pub struct ThreadItem { timestamp: SharedString, notified: bool, status: AgentThreadStatus, + generating_title: bool, selected: bool, focused: bool, hovered: bool, @@ -48,6 +50,7 @@ impl ThreadItem { timestamp: "".into(), notified: false, status: AgentThreadStatus::default(), + generating_title: false, selected: false, focused: false, hovered: false, @@ -89,6 +92,11 @@ impl ThreadItem { self } + pub fn generating_title(mut self, generating: bool) -> Self { + self.generating_title = generating; + self + } + pub fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -221,7 +229,18 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label = if highlight_positions.is_empty() { + let title_label = if self.generating_title { + Label::new("New Thread…") + .color(Color::Muted) + .with_animation( + "generating-title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any_element() + } else if highlight_positions.is_empty() { Label::new(title).into_any_element() } else { HighlightedLabel::new(title, highlight_positions).into_any_element() @@ -283,7 +302,7 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(self.hovered || self.selected, |this| { + .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { let overlay = GradientFade::new( base_bg,