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