From 00292450e06f2caa3a3749dcf399cd85c6b6c3e7 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 9 May 2025 12:55:40 -0300 Subject: [PATCH] agent: Show delete thread icon buttons on hover/focus (#30370) This PR's main goal is to show the delete thread button when the list item is either focused or hovered. In order to do that, we ended up refactoring (i.e., merging) the `PastThread` and `PastContext` elements into a single `HistoryElementEntry` that already matches to the entry type (i.e., context or thread). Release Notes: - agent: Simplify the UI by showing the delete thread icon button only on hover or focus. --------- Co-authored-by: Agus Zubiaga --- crates/agent/src/agent_panel.rs | 32 +- crates/agent/src/thread_history.rs | 354 ++++++++++----------- crates/ui/src/components/list/list_item.rs | 8 + 3 files changed, 186 insertions(+), 208 deletions(-) diff --git a/crates/agent/src/agent_panel.rs b/crates/agent/src/agent_panel.rs index fa203a1409..14b926674c 100644 --- a/crates/agent/src/agent_panel.rs +++ b/crates/agent/src/agent_panel.rs @@ -55,10 +55,10 @@ use zed_llm_client::UsageLimit; use crate::active_thread::{self, ActiveThread, ActiveThreadEvent}; use crate::agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}; use crate::agent_diff::AgentDiff; -use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry}; +use crate::history_store::{HistoryStore, RecentEntry}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; -use crate::thread_history::{EntryTimeFormat, PastContext, PastThread, ThreadHistory}; +use crate::thread_history::{HistoryEntryElement, ThreadHistory}; use crate::thread_store::ThreadStore; use crate::ui::AgentOnboardingModal; use crate::{ @@ -356,6 +356,7 @@ pub struct AgentPanel { previous_view: Option, history_store: Entity, history: Entity, + hovered_recent_history_item: Option, assistant_dropdown_menu_handle: PopoverMenuHandle, assistant_navigation_menu_handle: PopoverMenuHandle, assistant_navigation_menu: Option>, @@ -696,6 +697,7 @@ impl AgentPanel { previous_view: None, history_store: history_store.clone(), history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), + hovered_recent_history_item: None, assistant_dropdown_menu_handle: PopoverMenuHandle::default(), assistant_navigation_menu_handle: PopoverMenuHandle::default(), assistant_navigation_menu: None, @@ -2212,7 +2214,7 @@ impl AgentPanel { .border_b_1() .border_color(cx.theme().colors().border_variant) .child( - Label::new("Past Interactions") + Label::new("Recent") .size(LabelSize::Small) .color(Color::Muted), ) @@ -2237,18 +2239,20 @@ impl AgentPanel { v_flex() .gap_1() .children( - recent_history.into_iter().map(|entry| { + recent_history.into_iter().enumerate().map(|(index, entry)| { // TODO: Add keyboard navigation. - match entry { - HistoryEntry::Thread(thread) => { - PastThread::new(thread, cx.entity().downgrade(), false, vec![], EntryTimeFormat::DateAndTime) - .into_any_element() - } - HistoryEntry::Context(context) => { - PastContext::new(context, cx.entity().downgrade(), false, vec![], EntryTimeFormat::DateAndTime) - .into_any_element() - } - } + let is_hovered = self.hovered_recent_history_item == Some(index); + HistoryEntryElement::new(entry.clone(), cx.entity().downgrade()) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_recent_history_item = Some(index); + } else if this.hovered_recent_history_item == Some(index) { + this.hovered_recent_history_item = None; + } + cx.notify(); + })) + .into_any_element() }), ) ) diff --git a/crates/agent/src/thread_history.rs b/crates/agent/src/thread_history.rs index d8fdddc2c5..73c5b47001 100644 --- a/crates/agent/src/thread_history.rs +++ b/crates/agent/src/thread_history.rs @@ -2,12 +2,11 @@ use std::fmt::Display; use std::ops::Range; use std::sync::Arc; -use assistant_context_editor::SavedContextMetadata; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - App, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, + App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, }; use time::{OffsetDateTime, UtcOffset}; @@ -18,7 +17,6 @@ use ui::{ use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore}; -use crate::thread_store::SerializedThreadMetadata; use crate::{AgentPanel, RemoveSelectedThread}; pub struct ThreadHistory { @@ -26,11 +24,12 @@ pub struct ThreadHistory { history_store: Entity, scroll_handle: UniformListScrollHandle, selected_index: usize, + hovered_index: Option, search_editor: Entity, all_entries: Arc>, // When the search is empty, we display date separators between history entries // This vector contains an enum of either a separator or an actual entry - separated_items: Vec, + separated_items: Vec, // Maps entry indexes to list item indexes separated_item_indexes: Vec, _separated_items_task: Option>, @@ -52,7 +51,7 @@ enum SearchState { }, } -enum HistoryListItem { +enum ListItemType { BucketSeparator(TimeBucket), Entry { index: usize, @@ -60,11 +59,11 @@ enum HistoryListItem { }, } -impl HistoryListItem { +impl ListItemType { fn entry_index(&self) -> Option { match self { - HistoryListItem::BucketSeparator(_) => None, - HistoryListItem::Entry { index, .. } => Some(*index), + ListItemType::BucketSeparator(_) => None, + ListItemType::Entry { index, .. } => Some(*index), } } } @@ -102,6 +101,7 @@ impl ThreadHistory { history_store, scroll_handle, selected_index: 0, + hovered_index: None, search_state: SearchState::Empty, all_entries: Default::default(), separated_items: Default::default(), @@ -160,11 +160,11 @@ impl ThreadHistory { if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); - items.push(HistoryListItem::BucketSeparator(entry_bucket)); + items.push(ListItemType::BucketSeparator(entry_bucket)); } indexes.push(items.len() as u32); - items.push(HistoryListItem::Entry { + items.push(ListItemType::Entry { index, format: entry_bucket.into(), }); @@ -467,7 +467,7 @@ impl ThreadHistory { .map(|(ix, m)| { self.render_list_item( Some(range_start + ix), - &HistoryListItem::Entry { + &ListItemType::Entry { index: m.candidate_id, format: EntryTimeFormat::DateAndTime, }, @@ -485,25 +485,36 @@ impl ThreadHistory { fn render_list_item( &self, list_entry_ix: Option, - item: &HistoryListItem, + item: &ListItemType, highlight_positions: Vec, - cx: &App, + cx: &Context, ) -> AnyElement { match item { - HistoryListItem::Entry { index, format } => match self.all_entries.get(*index) { + ListItemType::Entry { index, format } => match self.all_entries.get(*index) { Some(entry) => h_flex() .w_full() .pb_1() - .child(self.render_history_entry( - entry, - list_entry_ix == Some(self.selected_index), - highlight_positions, - *format, - )) + .child( + HistoryEntryElement::new(entry.clone(), self.agent_panel.clone()) + .highlight_positions(highlight_positions) + .timestamp_format(*format) + .selected(list_entry_ix == Some(self.selected_index)) + .hovered(list_entry_ix == self.hovered_index) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = list_entry_ix; + } else if this.hovered_index == list_entry_ix { + this.hovered_index = None; + } + + cx.notify(); + })) + .into_any_element(), + ) .into_any(), None => Empty.into_any_element(), }, - HistoryListItem::BucketSeparator(bucket) => div() + ListItemType::BucketSeparator(bucket) => div() .px(DynamicSpacing::Base06.rems(cx)) .pt_2() .pb_1() @@ -515,33 +526,6 @@ impl ThreadHistory { .into_any_element(), } } - - fn render_history_entry( - &self, - entry: &HistoryEntry, - is_active: bool, - highlight_positions: Vec, - format: EntryTimeFormat, - ) -> AnyElement { - match entry { - HistoryEntry::Thread(thread) => PastThread::new( - thread.clone(), - self.agent_panel.clone(), - is_active, - highlight_positions, - format, - ) - .into_any_element(), - HistoryEntry::Context(context) => PastContext::new( - context.clone(), - self.agent_panel.clone(), - is_active, - highlight_positions, - format, - ) - .into_any_element(), - } - } } impl Focusable for ThreadHistory { @@ -623,155 +607,97 @@ impl Render for ThreadHistory { } #[derive(IntoElement)] -pub struct PastThread { - thread: SerializedThreadMetadata, +pub struct HistoryEntryElement { + entry: HistoryEntry, agent_panel: WeakEntity, selected: bool, + hovered: bool, highlight_positions: Vec, timestamp_format: EntryTimeFormat, + on_hover: Box, } -impl PastThread { - pub fn new( - thread: SerializedThreadMetadata, - agent_panel: WeakEntity, - selected: bool, - highlight_positions: Vec, - timestamp_format: EntryTimeFormat, - ) -> Self { +impl HistoryEntryElement { + pub fn new(entry: HistoryEntry, agent_panel: WeakEntity) -> Self { Self { - thread, + entry, agent_panel, - selected, - highlight_positions, - timestamp_format, + selected: false, + hovered: false, + highlight_positions: vec![], + timestamp_format: EntryTimeFormat::DateAndTime, + on_hover: Box::new(|_, _, _| {}), } } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn highlight_positions(mut self, positions: Vec) -> Self { + self.highlight_positions = positions; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } + + pub fn timestamp_format(mut self, format: EntryTimeFormat) -> Self { + self.timestamp_format = format; + self + } } -impl RenderOnce for PastThread { +impl RenderOnce for HistoryEntryElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let summary = self.thread.summary; + let (id, summary, timestamp) = match &self.entry { + HistoryEntry::Thread(thread) => ( + thread.id.to_string(), + thread.summary.clone(), + thread.updated_at.timestamp(), + ), + HistoryEntry::Context(context) => ( + context.path.to_string_lossy().to_string(), + context.title.clone().into(), + context.mtime.timestamp(), + ), + }; - let thread_timestamp = self.timestamp_format.format_timestamp( - &self.agent_panel, - self.thread.updated_at.timestamp(), - cx, - ); + let thread_timestamp = + self.timestamp_format + .format_timestamp(&self.agent_panel, timestamp, cx); - ListItem::new(SharedString::from(self.thread.id.to_string())) + ListItem::new(SharedString::from(id)) .rounded() .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) .start_slot( - div().max_w_4_5().child( - HighlightedLabel::new(summary, self.highlight_positions) - .size(LabelSize::Small) - .truncate(), - ), - ) - .end_slot( h_flex() - .gap_1p5() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(summary, self.highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) .child( Label::new(thread_timestamp) .color(Color::Muted) .size(LabelSize::XSmall), - ) - .child( - IconButton::new("delete", IconName::TrashAlt) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) - }) - .on_click({ - let agent_panel = self.agent_panel.clone(); - let id = self.thread.id.clone(); - move |_event, _window, cx| { - agent_panel - .update(cx, |this, cx| { - this.delete_thread(&id, cx).detach_and_log_err(cx); - }) - .ok(); - } - }), ), ) - .on_click({ - let agent_panel = self.agent_panel.clone(); - let id = self.thread.id.clone(); - move |_event, window, cx| { - agent_panel - .update(cx, |this, cx| { - this.open_thread_by_id(&id, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - } - }) - } -} - -#[derive(IntoElement)] -pub struct PastContext { - context: SavedContextMetadata, - agent_panel: WeakEntity, - selected: bool, - highlight_positions: Vec, - timestamp_format: EntryTimeFormat, -} - -impl PastContext { - pub fn new( - context: SavedContextMetadata, - agent_panel: WeakEntity, - selected: bool, - highlight_positions: Vec, - timestamp_format: EntryTimeFormat, - ) -> Self { - Self { - context, - agent_panel, - selected, - highlight_positions, - timestamp_format, - } - } -} - -impl RenderOnce for PastContext { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let summary = self.context.title; - let context_timestamp = self.timestamp_format.format_timestamp( - &self.agent_panel, - self.context.mtime.timestamp(), - cx, - ); - - ListItem::new(SharedString::from( - self.context.path.to_string_lossy().to_string(), - )) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - div().max_w_4_5().child( - HighlightedLabel::new(summary, self.highlight_positions) - .size(LabelSize::Small) - .truncate(), - ), - ) - .end_slot( - h_flex() - .gap_1p5() - .child( - Label::new(context_timestamp) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .child( + .on_hover(self.on_hover) + .end_slot::(if self.hovered || self.selected { + Some( IconButton::new("delete", IconName::TrashAlt) .shape(IconButtonShape::Square) .icon_size(IconSize::XSmall) @@ -781,30 +707,70 @@ impl RenderOnce for PastContext { }) .on_click({ let agent_panel = self.agent_panel.clone(); - let path = self.context.path.clone(); - move |_event, _window, cx| { - agent_panel - .update(cx, |this, cx| { - this.delete_context(path.clone(), cx) - .detach_and_log_err(cx); - }) - .ok(); - } + + let f: Box = + match &self.entry { + HistoryEntry::Thread(thread) => { + let id = thread.id.clone(); + + Box::new(move |_event, _window, cx| { + agent_panel + .update(cx, |this, cx| { + this.delete_thread(&id, cx) + .detach_and_log_err(cx); + }) + .ok(); + }) + } + HistoryEntry::Context(context) => { + let path = context.path.clone(); + + Box::new(move |_event, _window, cx| { + agent_panel + .update(cx, |this, cx| { + this.delete_context(path.clone(), cx) + .detach_and_log_err(cx); + }) + .ok(); + }) + } + }; + f }), - ), - ) - .on_click({ - let agent_panel = self.agent_panel.clone(); - let path = self.context.path.clone(); - move |_event, window, cx| { - agent_panel - .update(cx, |this, cx| { - this.open_saved_prompt_editor(path.clone(), window, cx) - .detach_and_log_err(cx); - }) - .ok(); - } - }) + ) + } else { + None + }) + .on_click({ + let agent_panel = self.agent_panel.clone(); + + let f: Box = match &self.entry + { + HistoryEntry::Thread(thread) => { + let id = thread.id.clone(); + Box::new(move |_event, window, cx| { + agent_panel + .update(cx, |this, cx| { + this.open_thread_by_id(&id, window, cx) + .detach_and_log_err(cx); + }) + .ok(); + }) + } + HistoryEntry::Context(context) => { + let path = context.path.clone(); + Box::new(move |_event, window, cx| { + agent_panel + .update(cx, |this, cx| { + this.open_saved_prompt_editor(path.clone(), window, cx) + .detach_and_log_err(cx); + }) + .ok(); + }) + } + }; + f + }) } } diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 9570301e2b..6356930aee 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -33,6 +33,7 @@ pub struct ListItem { toggle: Option, inset: bool, on_click: Option>, + on_hover: Option>, on_toggle: Option>, tooltip: Option AnyView + 'static>>, on_secondary_mouse_down: Option>, @@ -63,6 +64,7 @@ impl ListItem { on_click: None, on_secondary_mouse_down: None, on_toggle: None, + on_hover: None, tooltip: None, children: SmallVec::new(), selectable: true, @@ -102,6 +104,11 @@ impl ListItem { self } + pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Some(Box::new(handler)); + self + } + pub fn on_secondary_mouse_down( mut self, handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, @@ -233,6 +240,7 @@ impl RenderOnce for ListItem { }) }) .when(self.rounded, |this| this.rounded_sm()) + .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) .child( h_flex() .id("inner_list_item")