thread_history_view.rs

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