thread_history.rs

  1use agent::{HistoryEntry, HistoryStore};
  2use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
  3use editor::{Editor, EditorEvent};
  4use fuzzy::StringMatchCandidate;
  5use gpui::{
  6    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
  7    UniformListScrollHandle, Window, actions, uniform_list,
  8};
  9use std::{fmt::Display, ops::Range};
 10use text::Bias;
 11use time::{OffsetDateTime, UtcOffset};
 12use ui::{
 13    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar,
 14    prelude::*,
 15};
 16
 17actions!(
 18    agents,
 19    [
 20        /// Removes all thread history.
 21        RemoveHistory,
 22        /// Removes the currently selected thread.
 23        RemoveSelectedThread,
 24    ]
 25);
 26
 27pub struct AcpThreadHistory {
 28    pub(crate) history_store: Entity<HistoryStore>,
 29    scroll_handle: UniformListScrollHandle,
 30    selected_index: usize,
 31    hovered_index: Option<usize>,
 32    search_editor: Entity<Editor>,
 33    search_query: SharedString,
 34    visible_items: Vec<ListItemType>,
 35    local_timezone: UtcOffset,
 36    confirming_delete_history: bool,
 37    _update_task: Task<()>,
 38    _subscriptions: Vec<gpui::Subscription>,
 39}
 40
 41enum ListItemType {
 42    BucketSeparator(TimeBucket),
 43    Entry {
 44        entry: HistoryEntry,
 45        format: EntryTimeFormat,
 46    },
 47    SearchResult {
 48        entry: HistoryEntry,
 49        positions: Vec<usize>,
 50    },
 51}
 52
 53impl ListItemType {
 54    fn history_entry(&self) -> Option<&HistoryEntry> {
 55        match self {
 56            ListItemType::Entry { entry, .. } => Some(entry),
 57            ListItemType::SearchResult { entry, .. } => Some(entry),
 58            _ => None,
 59        }
 60    }
 61}
 62
 63#[allow(dead_code)]
 64pub enum ThreadHistoryEvent {
 65    Open(HistoryEntry),
 66}
 67
 68impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
 69
 70impl AcpThreadHistory {
 71    pub fn new(
 72        history_store: Entity<agent::HistoryStore>,
 73        window: &mut Window,
 74        cx: &mut Context<Self>,
 75    ) -> Self {
 76        let search_editor = cx.new(|cx| {
 77            let mut editor = Editor::single_line(window, cx);
 78            editor.set_placeholder_text("Search threads...", window, cx);
 79            editor
 80        });
 81
 82        let search_editor_subscription =
 83            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
 84                if let EditorEvent::BufferEdited = event {
 85                    let query = search_editor.read(cx).text(cx);
 86                    if this.search_query != query {
 87                        this.search_query = query.into();
 88                        this.update_visible_items(false, cx);
 89                    }
 90                }
 91            });
 92
 93        let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
 94            this.update_visible_items(true, cx);
 95        });
 96
 97        let scroll_handle = UniformListScrollHandle::default();
 98
 99        let mut this = Self {
100            history_store,
101            scroll_handle,
102            selected_index: 0,
103            hovered_index: None,
104            visible_items: Default::default(),
105            search_editor,
106            local_timezone: UtcOffset::from_whole_seconds(
107                chrono::Local::now().offset().local_minus_utc(),
108            )
109            .unwrap(),
110            search_query: SharedString::default(),
111            confirming_delete_history: false,
112            _subscriptions: vec![search_editor_subscription, history_store_subscription],
113            _update_task: Task::ready(()),
114        };
115        this.update_visible_items(false, cx);
116        this
117    }
118
119    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
120        let entries = self
121            .history_store
122            .update(cx, |store, _| store.entries().collect());
123        let new_list_items = if self.search_query.is_empty() {
124            self.add_list_separators(entries, cx)
125        } else {
126            self.filter_search_results(entries, cx)
127        };
128        let selected_history_entry = if preserve_selected_item {
129            self.selected_history_entry().cloned()
130        } else {
131            None
132        };
133
134        self._update_task = cx.spawn(async move |this, cx| {
135            let new_visible_items = new_list_items.await;
136            this.update(cx, |this, cx| {
137                let new_selected_index = if let Some(history_entry) = selected_history_entry {
138                    let history_entry_id = history_entry.id();
139                    new_visible_items
140                        .iter()
141                        .position(|visible_entry| {
142                            visible_entry
143                                .history_entry()
144                                .is_some_and(|entry| entry.id() == history_entry_id)
145                        })
146                        .unwrap_or(0)
147                } else {
148                    0
149                };
150
151                this.visible_items = new_visible_items;
152                this.set_selected_index(new_selected_index, Bias::Right, cx);
153                cx.notify();
154            })
155            .ok();
156        });
157    }
158
159    fn add_list_separators(&self, entries: Vec<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
160        cx.background_spawn(async move {
161            let mut items = Vec::with_capacity(entries.len() + 1);
162            let mut bucket = None;
163            let today = Local::now().naive_local().date();
164
165            for entry in entries.into_iter() {
166                let entry_date = entry
167                    .updated_at()
168                    .with_timezone(&Local)
169                    .naive_local()
170                    .date();
171                let entry_bucket = TimeBucket::from_dates(today, entry_date);
172
173                if Some(entry_bucket) != bucket {
174                    bucket = Some(entry_bucket);
175                    items.push(ListItemType::BucketSeparator(entry_bucket));
176                }
177
178                items.push(ListItemType::Entry {
179                    entry,
180                    format: entry_bucket.into(),
181                });
182            }
183            items
184        })
185    }
186
187    fn filter_search_results(
188        &self,
189        entries: Vec<HistoryEntry>,
190        cx: &App,
191    ) -> Task<Vec<ListItemType>> {
192        let query = self.search_query.clone();
193        cx.background_spawn({
194            let executor = cx.background_executor().clone();
195            async move {
196                let mut candidates = Vec::with_capacity(entries.len());
197
198                for (idx, entry) in entries.iter().enumerate() {
199                    candidates.push(StringMatchCandidate::new(idx, entry.title()));
200                }
201
202                const MAX_MATCHES: usize = 100;
203
204                let matches = fuzzy::match_strings(
205                    &candidates,
206                    &query,
207                    false,
208                    true,
209                    MAX_MATCHES,
210                    &Default::default(),
211                    executor,
212                )
213                .await;
214
215                matches
216                    .into_iter()
217                    .map(|search_match| ListItemType::SearchResult {
218                        entry: entries[search_match.candidate_id].clone(),
219                        positions: search_match.positions,
220                    })
221                    .collect()
222            }
223        })
224    }
225
226    fn search_produced_no_matches(&self) -> bool {
227        self.visible_items.is_empty() && !self.search_query.is_empty()
228    }
229
230    fn selected_history_entry(&self) -> Option<&HistoryEntry> {
231        self.get_history_entry(self.selected_index)
232    }
233
234    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> {
235        self.visible_items.get(visible_items_ix)?.history_entry()
236    }
237
238    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
239        if self.visible_items.is_empty() {
240            self.selected_index = 0;
241            return;
242        }
243        while matches!(
244            self.visible_items.get(index),
245            None | Some(ListItemType::BucketSeparator(..))
246        ) {
247            index = match bias {
248                Bias::Left => {
249                    if index == 0 {
250                        self.visible_items.len() - 1
251                    } else {
252                        index - 1
253                    }
254                }
255                Bias::Right => {
256                    if index >= self.visible_items.len() - 1 {
257                        0
258                    } else {
259                        index + 1
260                    }
261                }
262            };
263        }
264        self.selected_index = index;
265        self.scroll_handle
266            .scroll_to_item(index, ScrollStrategy::Top);
267        cx.notify()
268    }
269
270    pub fn select_previous(
271        &mut self,
272        _: &menu::SelectPrevious,
273        _window: &mut Window,
274        cx: &mut Context<Self>,
275    ) {
276        if self.selected_index == 0 {
277            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
278        } else {
279            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
280        }
281    }
282
283    pub fn select_next(
284        &mut self,
285        _: &menu::SelectNext,
286        _window: &mut Window,
287        cx: &mut Context<Self>,
288    ) {
289        if self.selected_index == self.visible_items.len() - 1 {
290            self.set_selected_index(0, Bias::Right, cx);
291        } else {
292            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
293        }
294    }
295
296    fn select_first(
297        &mut self,
298        _: &menu::SelectFirst,
299        _window: &mut Window,
300        cx: &mut Context<Self>,
301    ) {
302        self.set_selected_index(0, Bias::Right, cx);
303    }
304
305    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
306        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
307    }
308
309    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
310        self.confirm_entry(self.selected_index, cx);
311    }
312
313    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
314        let Some(entry) = self.get_history_entry(ix) else {
315            return;
316        };
317        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
318    }
319
320    fn remove_selected_thread(
321        &mut self,
322        _: &RemoveSelectedThread,
323        _window: &mut Window,
324        cx: &mut Context<Self>,
325    ) {
326        self.remove_thread(self.selected_index, cx)
327    }
328
329    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
330        let Some(entry) = self.get_history_entry(visible_item_ix) else {
331            return;
332        };
333
334        let task = match entry {
335            HistoryEntry::AcpThread(thread) => self
336                .history_store
337                .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
338            HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| {
339                this.delete_text_thread(text_thread.path.clone(), cx)
340            }),
341        };
342        task.detach_and_log_err(cx);
343    }
344
345    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
346        self.history_store.update(cx, |store, cx| {
347            store.delete_threads(cx).detach_and_log_err(cx)
348        });
349        self.confirming_delete_history = false;
350        cx.notify();
351    }
352
353    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
354        self.confirming_delete_history = true;
355        cx.notify();
356    }
357
358    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
359        self.confirming_delete_history = false;
360        cx.notify();
361    }
362
363    fn render_list_items(
364        &mut self,
365        range: Range<usize>,
366        _window: &mut Window,
367        cx: &mut Context<Self>,
368    ) -> Vec<AnyElement> {
369        self.visible_items
370            .get(range.clone())
371            .into_iter()
372            .flatten()
373            .enumerate()
374            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
375            .collect()
376    }
377
378    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
379        match item {
380            ListItemType::Entry { entry, format } => self
381                .render_history_entry(entry, *format, ix, Vec::default(), cx)
382                .into_any(),
383            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
384                entry,
385                EntryTimeFormat::DateAndTime,
386                ix,
387                positions.clone(),
388                cx,
389            ),
390            ListItemType::BucketSeparator(bucket) => div()
391                .px(DynamicSpacing::Base06.rems(cx))
392                .pt_2()
393                .pb_1()
394                .child(
395                    Label::new(bucket.to_string())
396                        .size(LabelSize::XSmall)
397                        .color(Color::Muted),
398                )
399                .into_any_element(),
400        }
401    }
402
403    fn render_history_entry(
404        &self,
405        entry: &HistoryEntry,
406        format: EntryTimeFormat,
407        ix: usize,
408        highlight_positions: Vec<usize>,
409        cx: &Context<Self>,
410    ) -> AnyElement {
411        let selected = ix == self.selected_index;
412        let hovered = Some(ix) == self.hovered_index;
413        let timestamp = entry.updated_at().timestamp();
414        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
415
416        h_flex()
417            .w_full()
418            .pb_1()
419            .child(
420                ListItem::new(ix)
421                    .rounded()
422                    .toggle_state(selected)
423                    .spacing(ListItemSpacing::Sparse)
424                    .start_slot(
425                        h_flex()
426                            .w_full()
427                            .gap_2()
428                            .justify_between()
429                            .child(
430                                HighlightedLabel::new(entry.title(), highlight_positions)
431                                    .size(LabelSize::Small)
432                                    .truncate(),
433                            )
434                            .child(
435                                Label::new(thread_timestamp)
436                                    .color(Color::Muted)
437                                    .size(LabelSize::XSmall),
438                            ),
439                    )
440                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
441                        if *is_hovered {
442                            this.hovered_index = Some(ix);
443                        } else if this.hovered_index == Some(ix) {
444                            this.hovered_index = None;
445                        }
446
447                        cx.notify();
448                    }))
449                    .end_slot::<IconButton>(if hovered {
450                        Some(
451                            IconButton::new("delete", IconName::Trash)
452                                .shape(IconButtonShape::Square)
453                                .icon_size(IconSize::XSmall)
454                                .icon_color(Color::Muted)
455                                .tooltip(move |_window, cx| {
456                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
457                                })
458                                .on_click(cx.listener(move |this, _, _, cx| {
459                                    this.remove_thread(ix, cx);
460                                    cx.stop_propagation()
461                                })),
462                        )
463                    } else {
464                        None
465                    })
466                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
467            )
468            .into_any_element()
469    }
470}
471
472impl Focusable for AcpThreadHistory {
473    fn focus_handle(&self, cx: &App) -> FocusHandle {
474        self.search_editor.focus_handle(cx)
475    }
476}
477
478impl Render for AcpThreadHistory {
479    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
480        let has_no_history = self.history_store.read(cx).is_empty(cx);
481
482        v_flex()
483            .key_context("ThreadHistory")
484            .size_full()
485            .bg(cx.theme().colors().panel_background)
486            .on_action(cx.listener(Self::select_previous))
487            .on_action(cx.listener(Self::select_next))
488            .on_action(cx.listener(Self::select_first))
489            .on_action(cx.listener(Self::select_last))
490            .on_action(cx.listener(Self::confirm))
491            .on_action(cx.listener(Self::remove_selected_thread))
492            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
493                this.remove_history(window, cx);
494            }))
495            .child(
496                h_flex()
497                    .h(Tab::container_height(cx))
498                    .w_full()
499                    .py_1()
500                    .px_2()
501                    .gap_2()
502                    .justify_between()
503                    .border_b_1()
504                    .border_color(cx.theme().colors().border)
505                    .child(
506                        Icon::new(IconName::MagnifyingGlass)
507                            .color(Color::Muted)
508                            .size(IconSize::Small),
509                    )
510                    .child(self.search_editor.clone()),
511            )
512            .child({
513                let view = v_flex()
514                    .id("list-container")
515                    .relative()
516                    .overflow_hidden()
517                    .flex_grow();
518
519                if has_no_history {
520                    view.justify_center().items_center().child(
521                        Label::new("You don't have any past threads yet.")
522                            .size(LabelSize::Small)
523                            .color(Color::Muted),
524                    )
525                } else if self.search_produced_no_matches() {
526                    view.justify_center()
527                        .items_center()
528                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
529                } else {
530                    view.child(
531                        uniform_list(
532                            "thread-history",
533                            self.visible_items.len(),
534                            cx.processor(|this, range: Range<usize>, window, cx| {
535                                this.render_list_items(range, window, cx)
536                            }),
537                        )
538                        .p_1()
539                        .pr_4()
540                        .track_scroll(&self.scroll_handle)
541                        .flex_grow(),
542                    )
543                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
544                }
545            })
546            .when(!has_no_history, |this| {
547                this.child(
548                    h_flex()
549                        .p_2()
550                        .border_t_1()
551                        .border_color(cx.theme().colors().border_variant)
552                        .when(!self.confirming_delete_history, |this| {
553                            this.child(
554                                Button::new("delete_history", "Delete All History")
555                                    .full_width()
556                                    .style(ButtonStyle::Outlined)
557                                    .label_size(LabelSize::Small)
558                                    .on_click(cx.listener(|this, _, window, cx| {
559                                        this.prompt_delete_history(window, cx);
560                                    })),
561                            )
562                        })
563                        .when(self.confirming_delete_history, |this| {
564                            this.w_full()
565                                .gap_2()
566                                .flex_wrap()
567                                .justify_between()
568                                .child(
569                                    h_flex()
570                                        .flex_wrap()
571                                        .gap_1()
572                                        .child(
573                                            Label::new("Delete all threads?")
574                                                .size(LabelSize::Small),
575                                        )
576                                        .child(
577                                            Label::new("You won't be able to recover them later.")
578                                                .size(LabelSize::Small)
579                                                .color(Color::Muted),
580                                        ),
581                                )
582                                .child(
583                                    h_flex()
584                                        .gap_1()
585                                        .child(
586                                            Button::new("cancel_delete", "Cancel")
587                                                .label_size(LabelSize::Small)
588                                                .on_click(cx.listener(|this, _, window, cx| {
589                                                    this.cancel_delete_history(window, cx);
590                                                })),
591                                        )
592                                        .child(
593                                            Button::new("confirm_delete", "Delete")
594                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
595                                                .color(Color::Error)
596                                                .label_size(LabelSize::Small)
597                                                .on_click(cx.listener(|_, _, window, cx| {
598                                                    window.dispatch_action(
599                                                        Box::new(RemoveHistory),
600                                                        cx,
601                                                    );
602                                                })),
603                                        ),
604                                )
605                        }),
606                )
607            })
608    }
609}
610
611#[derive(Clone, Copy)]
612pub enum EntryTimeFormat {
613    DateAndTime,
614    TimeOnly,
615}
616
617impl EntryTimeFormat {
618    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
619        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
620
621        match self {
622            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
623                timestamp,
624                OffsetDateTime::now_utc(),
625                timezone,
626                time_format::TimestampFormat::EnhancedAbsolute,
627            ),
628            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
629        }
630    }
631}
632
633impl From<TimeBucket> for EntryTimeFormat {
634    fn from(bucket: TimeBucket) -> Self {
635        match bucket {
636            TimeBucket::Today => EntryTimeFormat::TimeOnly,
637            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
638            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
639            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
640            TimeBucket::All => EntryTimeFormat::DateAndTime,
641        }
642    }
643}
644
645#[derive(PartialEq, Eq, Clone, Copy, Debug)]
646enum TimeBucket {
647    Today,
648    Yesterday,
649    ThisWeek,
650    PastWeek,
651    All,
652}
653
654impl TimeBucket {
655    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
656        if date == reference {
657            return TimeBucket::Today;
658        }
659
660        if date == reference - TimeDelta::days(1) {
661            return TimeBucket::Yesterday;
662        }
663
664        let week = date.iso_week();
665
666        if reference.iso_week() == week {
667            return TimeBucket::ThisWeek;
668        }
669
670        let last_week = (reference - TimeDelta::days(7)).iso_week();
671
672        if week == last_week {
673            return TimeBucket::PastWeek;
674        }
675
676        TimeBucket::All
677    }
678}
679
680impl Display for TimeBucket {
681    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682        match self {
683            TimeBucket::Today => write!(f, "Today"),
684            TimeBucket::Yesterday => write!(f, "Yesterday"),
685            TimeBucket::ThisWeek => write!(f, "This Week"),
686            TimeBucket::PastWeek => write!(f, "Past Week"),
687            TimeBucket::All => write!(f, "All"),
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use chrono::NaiveDate;
696
697    #[test]
698    fn test_time_bucket_from_dates() {
699        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
700
701        let date = today;
702        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
703
704        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
705        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
706
707        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
708        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
709
710        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
711        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
712
713        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
714        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
715
716        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
717        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
718
719        // All: not in this week or last week
720        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
721        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
722
723        // Test year boundary cases
724        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
725
726        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
727        assert_eq!(
728            TimeBucket::from_dates(new_year, date),
729            TimeBucket::Yesterday
730        );
731
732        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
733        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
734    }
735}