use std::fmt::Display; use std::ops::Range; use std::sync::Arc; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; use editor::{Editor, EditorEvent}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, }; use time::{OffsetDateTime, UtcOffset}; use ui::{ HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, prelude::*, }; use util::ResultExt; use crate::history_store::{HistoryEntry, HistoryStore}; use crate::{AgentPanel, RemoveSelectedThread}; pub struct ThreadHistory { agent_panel: WeakEntity, 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, // Maps entry indexes to list item indexes separated_item_indexes: Vec, _separated_items_task: Option>, search_state: SearchState, scrollbar_visibility: bool, scrollbar_state: ScrollbarState, _subscriptions: Vec, } enum SearchState { Empty, Searching { query: SharedString, _task: Task<()>, }, Searched { query: SharedString, matches: Vec, }, } enum ListItemType { BucketSeparator(TimeBucket), Entry { index: usize, format: EntryTimeFormat, }, } impl ListItemType { fn entry_index(&self) -> Option { match self { ListItemType::BucketSeparator(_) => None, ListItemType::Entry { index, .. } => Some(*index), } } } impl ThreadHistory { pub(crate) fn new( agent_panel: WeakEntity, history_store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let search_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Search threads...", cx); editor }); let search_editor_subscription = cx.subscribe(&search_editor, |this, search_editor, event, cx| { if let EditorEvent::BufferEdited = event { let query = search_editor.read(cx).text(cx); this.search(query.into(), cx); } }); let history_store_subscription = cx.observe(&history_store, |this, _, cx| { this.update_all_entries(cx); }); let scroll_handle = UniformListScrollHandle::default(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { agent_panel, history_store, scroll_handle, selected_index: 0, hovered_index: None, search_state: SearchState::Empty, all_entries: Default::default(), separated_items: Default::default(), separated_item_indexes: Default::default(), search_editor, scrollbar_visibility: true, scrollbar_state, _subscriptions: vec![search_editor_subscription, history_store_subscription], _separated_items_task: None, }; this.update_all_entries(cx); this } fn update_all_entries(&mut self, cx: &mut Context) { let new_entries: Arc> = self .history_store .update(cx, |store, cx| store.entries(cx)) .into(); self._separated_items_task.take(); let mut items = Vec::with_capacity(new_entries.len() + 1); let mut indexes = Vec::with_capacity(new_entries.len() + 1); let bg_task = cx.background_spawn(async move { let mut bucket = None; let today = Local::now().naive_local().date(); for (index, entry) in new_entries.iter().enumerate() { let entry_date = entry .updated_at() .with_timezone(&Local) .naive_local() .date(); let entry_bucket = TimeBucket::from_dates(today, entry_date); if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); items.push(ListItemType::BucketSeparator(entry_bucket)); } indexes.push(items.len() as u32); items.push(ListItemType::Entry { index, format: entry_bucket.into(), }); } (new_entries, items, indexes) }); let task = cx.spawn(async move |this, cx| { let (new_entries, items, indexes) = bg_task.await; this.update(cx, |this, cx| { let previously_selected_entry = this.all_entries.get(this.selected_index).map(|e| e.id()); this.all_entries = new_entries; this.separated_items = items; this.separated_item_indexes = indexes; match &this.search_state { SearchState::Empty => { if this.selected_index >= this.all_entries.len() { this.set_selected_entry_index( this.all_entries.len().saturating_sub(1), cx, ); } else if let Some(prev_id) = previously_selected_entry { if let Some(new_ix) = this .all_entries .iter() .position(|probe| probe.id() == prev_id) { this.set_selected_entry_index(new_ix, cx); } } } SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => { this.search(query.clone(), cx); } } cx.notify(); }) .log_err(); }); self._separated_items_task = Some(task); } fn search(&mut self, query: SharedString, cx: &mut Context) { if query.is_empty() { self.search_state = SearchState::Empty; cx.notify(); return; } let all_entries = self.all_entries.clone(); let fuzzy_search_task = cx.background_spawn({ let query = query.clone(); let executor = cx.background_executor().clone(); async move { let mut candidates = Vec::with_capacity(all_entries.len()); for (idx, entry) in all_entries.iter().enumerate() { match entry { HistoryEntry::Thread(thread) => { candidates.push(StringMatchCandidate::new(idx, &thread.summary)); } HistoryEntry::Context(context) => { candidates.push(StringMatchCandidate::new(idx, &context.title)); } } } const MAX_MATCHES: usize = 100; fuzzy::match_strings( &candidates, &query, false, MAX_MATCHES, &Default::default(), executor, ) .await } }); let task = cx.spawn({ let query = query.clone(); async move |this, cx| { let matches = fuzzy_search_task.await; this.update(cx, |this, cx| { let SearchState::Searching { query: current_query, _task, } = &this.search_state else { return; }; if &query == current_query { this.search_state = SearchState::Searched { query: query.clone(), matches, }; this.set_selected_entry_index(0, cx); cx.notify(); }; }) .log_err(); } }); self.search_state = SearchState::Searching { query, _task: task }; cx.notify(); } fn matched_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.all_entries.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn list_item_count(&self) -> usize { match &self.search_state { SearchState::Empty => self.separated_items.len(), SearchState::Searching { .. } => 0, SearchState::Searched { matches, .. } => matches.len(), } } fn search_produced_no_matches(&self) -> bool { match &self.search_state { SearchState::Empty => false, SearchState::Searching { .. } => false, SearchState::Searched { matches, .. } => matches.is_empty(), } } fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { match &self.search_state { SearchState::Empty => self.all_entries.get(ix), SearchState::Searching { .. } => None, SearchState::Searched { matches, .. } => matches .get(ix) .and_then(|m| self.all_entries.get(m.candidate_id)), } } pub fn select_previous( &mut self, _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == 0 { self.set_selected_entry_index(count - 1, cx); } else { self.set_selected_entry_index(self.selected_index - 1, cx); } } } pub fn select_next( &mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context, ) { let count = self.matched_count(); if count > 0 { if self.selected_index == count - 1 { self.set_selected_entry_index(0, cx); } else { self.set_selected_entry_index(self.selected_index + 1, cx); } } } fn select_first( &mut self, _: &menu::SelectFirst, _window: &mut Window, cx: &mut Context, ) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(0, cx); } } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { let count = self.matched_count(); if count > 0 { self.set_selected_entry_index(count - 1, cx); } } fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context) { self.selected_index = entry_index; let scroll_ix = match self.search_state { SearchState::Empty | SearchState::Searching { .. } => self .separated_item_indexes .get(entry_index) .map(|ix| *ix as usize) .unwrap_or(entry_index + 1), SearchState::Searched { .. } => entry_index, }; self.scroll_handle .scroll_to_item(scroll_ix, ScrollStrategy::Top); cx.notify(); } fn render_scrollbar(&self, cx: &mut Context) -> Option> { if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { return None; } Some( div() .occlude() .id("thread-history-scroll") .h_full() .bg(cx.theme().colors().panel_background.opacity(0.8)) .border_l_1() .border_color(cx.theme().colors().border_variant) .absolute() .right_1() .top_0() .bottom_0() .w_4() .pl_1() .cursor_default() .on_mouse_move(cx.listener(|_, _, _window, cx| { cx.notify(); cx.stop_propagation() })) .on_hover(|_, _window, cx| { cx.stop_propagation(); }) .on_any_mouse_down(|_, _window, cx| { cx.stop_propagation(); }) .on_scroll_wheel(cx.listener(|_, _, _window, cx| { cx.notify(); })) .children(Scrollbar::vertical(self.scrollbar_state.clone())), ) } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self.agent_panel.update(cx, move |this, cx| { this.open_thread_by_id(&thread.id, window, cx) }), HistoryEntry::Context(context) => self.agent_panel.update(cx, move |this, cx| { this.open_saved_prompt_editor(context.path.clone(), window, cx) }), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn remove_selected_thread( &mut self, _: &RemoveSelectedThread, _window: &mut Window, cx: &mut Context, ) { if let Some(entry) = self.get_match(self.selected_index) { let task_result = match entry { HistoryEntry::Thread(thread) => self .agent_panel .update(cx, |this, cx| this.delete_thread(&thread.id, cx)), HistoryEntry::Context(context) => self .agent_panel .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)), }; if let Some(task) = task_result.log_err() { task.detach_and_log_err(cx); }; cx.notify(); } } fn list_items( &mut self, range: Range, _window: &mut Window, cx: &mut Context, ) -> Vec { let range_start = range.start; match &self.search_state { SearchState::Empty => self .separated_items .get(range) .iter() .flat_map(|items| { items .iter() .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx)) }) .collect(), SearchState::Searched { matches, .. } => matches[range] .iter() .enumerate() .map(|(ix, m)| { self.render_list_item( Some(range_start + ix), &ListItemType::Entry { index: m.candidate_id, format: EntryTimeFormat::DateAndTime, }, m.positions.clone(), cx, ) }) .collect(), SearchState::Searching { .. } => { vec![] } } } fn render_list_item( &self, list_entry_ix: Option, item: &ListItemType, highlight_positions: Vec, cx: &Context, ) -> AnyElement { match item { ListItemType::Entry { index, format } => match self.all_entries.get(*index) { Some(entry) => h_flex() .w_full() .pb_1() .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(), }, ListItemType::BucketSeparator(bucket) => div() .px(DynamicSpacing::Base06.rems(cx)) .pt_2() .pb_1() .child( Label::new(bucket.to_string()) .size(LabelSize::XSmall) .color(Color::Muted), ) .into_any_element(), } } } impl Focusable for ThreadHistory { fn focus_handle(&self, cx: &App) -> FocusHandle { self.search_editor.focus_handle(cx) } } impl Render for ThreadHistory { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("ThreadHistory") .size_full() .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_next)) .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)) .when(!self.all_entries.is_empty(), |parent| { parent.child( h_flex() .h(px(41.)) // Match the toolbar perfectly .w_full() .py_1() .px_2() .gap_2() .justify_between() .border_b_1() .border_color(cx.theme().colors().border) .child( Icon::new(IconName::MagnifyingGlass) .color(Color::Muted) .size(IconSize::Small), ) .child(self.search_editor.clone()), ) }) .child({ let view = v_flex() .id("list-container") .relative() .overflow_hidden() .flex_grow(); if self.all_entries.is_empty() { view.justify_center() .child( h_flex().w_full().justify_center().child( Label::new("You don't have any past threads yet.") .size(LabelSize::Small), ), ) } else if self.search_produced_no_matches() { view.justify_center().child( h_flex().w_full().justify_center().child( Label::new("No threads match your search.").size(LabelSize::Small), ), ) } else { view.pr_5() .child( uniform_list( "thread-history", self.list_item_count(), cx.processor(|this, range: Range, window, cx| { this.list_items(range, window, cx) }), ) .p_1() .track_scroll(self.scroll_handle.clone()) .flex_grow(), ) .when_some(self.render_scrollbar(cx), |div, scrollbar| { div.child(scrollbar) }) } }) } } #[derive(IntoElement)] pub struct HistoryEntryElement { entry: HistoryEntry, agent_panel: WeakEntity, selected: bool, hovered: bool, highlight_positions: Vec, timestamp_format: EntryTimeFormat, on_hover: Box, } impl HistoryEntryElement { pub fn new(entry: HistoryEntry, agent_panel: WeakEntity) -> Self { Self { entry, agent_panel, 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 HistoryEntryElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { 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(), context.mtime.timestamp(), ), }; let thread_timestamp = self.timestamp_format .format_timestamp(&self.agent_panel, timestamp, cx); ListItem::new(SharedString::from(id)) .rounded() .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) .start_slot( h_flex() .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), ), ) .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) .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 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 }), ) } 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 }) } } #[derive(Clone, Copy)] pub enum EntryTimeFormat { DateAndTime, TimeOnly, } impl EntryTimeFormat { fn format_timestamp( &self, agent_panel: &WeakEntity, timestamp: i64, cx: &App, ) -> String { let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); let timezone = agent_panel .read_with(cx, |this, _cx| this.local_timezone()) .unwrap_or(UtcOffset::UTC); match &self { EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( timestamp, OffsetDateTime::now_utc(), timezone, time_format::TimestampFormat::EnhancedAbsolute, ), EntryTimeFormat::TimeOnly => time_format::format_time(timestamp), } } } impl From for EntryTimeFormat { fn from(bucket: TimeBucket) -> Self { match bucket { TimeBucket::Today => EntryTimeFormat::TimeOnly, TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, TimeBucket::All => EntryTimeFormat::DateAndTime, } } } #[derive(PartialEq, Eq, Clone, Copy, Debug)] enum TimeBucket { Today, Yesterday, ThisWeek, PastWeek, All, } impl TimeBucket { fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { if date == reference { return TimeBucket::Today; } if date == reference - TimeDelta::days(1) { return TimeBucket::Yesterday; } let week = date.iso_week(); if reference.iso_week() == week { return TimeBucket::ThisWeek; } let last_week = (reference - TimeDelta::days(7)).iso_week(); if week == last_week { return TimeBucket::PastWeek; } TimeBucket::All } } impl Display for TimeBucket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimeBucket::Today => write!(f, "Today"), TimeBucket::Yesterday => write!(f, "Yesterday"), TimeBucket::ThisWeek => write!(f, "This Week"), TimeBucket::PastWeek => write!(f, "Past Week"), TimeBucket::All => write!(f, "All"), } } } #[cfg(test)] mod tests { use super::*; use chrono::NaiveDate; #[test] fn test_time_bucket_from_dates() { let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); let date = today; assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); // All: not in this week or last week let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); // Test year boundary cases let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); assert_eq!( TimeBucket::from_dates(new_year, date), TimeBucket::Yesterday ); let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); } }