diff --git a/crates/agent/src/agent_panel.rs b/crates/agent/src/agent_panel.rs index fa203a1409cdd2..14b926674c7042 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 d8fdddc2c5931a..73c5b47001d37f 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 9570301e2bf93a..6356930aee8cc3 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")