1use agent::{HistoryEntry, HistoryStore};
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
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
415 let display_text = match format {
416 EntryTimeFormat::DateAndTime => {
417 let entry_time = entry.updated_at();
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 => format.format_timestamp(timestamp, self.local_timezone),
425 };
426
427 let title = entry.title().clone();
428 let full_date =
429 EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
430
431 h_flex()
432 .w_full()
433 .pb_1()
434 .child(
435 ListItem::new(ix)
436 .rounded()
437 .toggle_state(selected)
438 .spacing(ListItemSpacing::Sparse)
439 .start_slot(
440 h_flex()
441 .w_full()
442 .gap_2()
443 .justify_between()
444 .child(
445 HighlightedLabel::new(entry.title(), highlight_positions)
446 .size(LabelSize::Small)
447 .truncate(),
448 )
449 .child(
450 Label::new(display_text)
451 .color(Color::Muted)
452 .size(LabelSize::XSmall),
453 ),
454 )
455 .tooltip(move |_, cx| {
456 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
457 })
458 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
459 if *is_hovered {
460 this.hovered_index = Some(ix);
461 } else if this.hovered_index == Some(ix) {
462 this.hovered_index = None;
463 }
464
465 cx.notify();
466 }))
467 .end_slot::<IconButton>(if hovered {
468 Some(
469 IconButton::new("delete", IconName::Trash)
470 .shape(IconButtonShape::Square)
471 .icon_size(IconSize::XSmall)
472 .icon_color(Color::Muted)
473 .tooltip(move |_window, cx| {
474 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
475 })
476 .on_click(cx.listener(move |this, _, _, cx| {
477 this.remove_thread(ix, cx);
478 cx.stop_propagation()
479 })),
480 )
481 } else {
482 None
483 })
484 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
485 )
486 .into_any_element()
487 }
488}
489
490impl Focusable for AcpThreadHistory {
491 fn focus_handle(&self, cx: &App) -> FocusHandle {
492 self.search_editor.focus_handle(cx)
493 }
494}
495
496impl Render for AcpThreadHistory {
497 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
498 let has_no_history = self.history_store.read(cx).is_empty(cx);
499
500 v_flex()
501 .key_context("ThreadHistory")
502 .size_full()
503 .bg(cx.theme().colors().panel_background)
504 .on_action(cx.listener(Self::select_previous))
505 .on_action(cx.listener(Self::select_next))
506 .on_action(cx.listener(Self::select_first))
507 .on_action(cx.listener(Self::select_last))
508 .on_action(cx.listener(Self::confirm))
509 .on_action(cx.listener(Self::remove_selected_thread))
510 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
511 this.remove_history(window, cx);
512 }))
513 .child(
514 h_flex()
515 .h(Tab::container_height(cx))
516 .w_full()
517 .py_1()
518 .px_2()
519 .gap_2()
520 .justify_between()
521 .border_b_1()
522 .border_color(cx.theme().colors().border)
523 .child(
524 Icon::new(IconName::MagnifyingGlass)
525 .color(Color::Muted)
526 .size(IconSize::Small),
527 )
528 .child(self.search_editor.clone()),
529 )
530 .child({
531 let view = v_flex()
532 .id("list-container")
533 .relative()
534 .overflow_hidden()
535 .flex_grow();
536
537 if has_no_history {
538 view.justify_center().items_center().child(
539 Label::new("You don't have any past threads yet.")
540 .size(LabelSize::Small)
541 .color(Color::Muted),
542 )
543 } else if self.search_produced_no_matches() {
544 view.justify_center()
545 .items_center()
546 .child(Label::new("No threads match your search.").size(LabelSize::Small))
547 } else {
548 view.child(
549 uniform_list(
550 "thread-history",
551 self.visible_items.len(),
552 cx.processor(|this, range: Range<usize>, window, cx| {
553 this.render_list_items(range, window, cx)
554 }),
555 )
556 .p_1()
557 .pr_4()
558 .track_scroll(&self.scroll_handle)
559 .flex_grow(),
560 )
561 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
562 }
563 })
564 .when(!has_no_history, |this| {
565 this.child(
566 h_flex()
567 .p_2()
568 .border_t_1()
569 .border_color(cx.theme().colors().border_variant)
570 .when(!self.confirming_delete_history, |this| {
571 this.child(
572 Button::new("delete_history", "Delete All History")
573 .full_width()
574 .style(ButtonStyle::Outlined)
575 .label_size(LabelSize::Small)
576 .on_click(cx.listener(|this, _, window, cx| {
577 this.prompt_delete_history(window, cx);
578 })),
579 )
580 })
581 .when(self.confirming_delete_history, |this| {
582 this.w_full()
583 .gap_2()
584 .flex_wrap()
585 .justify_between()
586 .child(
587 h_flex()
588 .flex_wrap()
589 .gap_1()
590 .child(
591 Label::new("Delete all threads?")
592 .size(LabelSize::Small),
593 )
594 .child(
595 Label::new("You won't be able to recover them later.")
596 .size(LabelSize::Small)
597 .color(Color::Muted),
598 ),
599 )
600 .child(
601 h_flex()
602 .gap_1()
603 .child(
604 Button::new("cancel_delete", "Cancel")
605 .label_size(LabelSize::Small)
606 .on_click(cx.listener(|this, _, window, cx| {
607 this.cancel_delete_history(window, cx);
608 })),
609 )
610 .child(
611 Button::new("confirm_delete", "Delete")
612 .style(ButtonStyle::Tinted(ui::TintColor::Error))
613 .color(Color::Error)
614 .label_size(LabelSize::Small)
615 .on_click(cx.listener(|_, _, window, cx| {
616 window.dispatch_action(
617 Box::new(RemoveHistory),
618 cx,
619 );
620 })),
621 ),
622 )
623 }),
624 )
625 })
626 }
627}
628
629#[derive(Clone, Copy)]
630pub enum EntryTimeFormat {
631 DateAndTime,
632 TimeOnly,
633}
634
635impl EntryTimeFormat {
636 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
637 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
638
639 match self {
640 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
641 timestamp,
642 OffsetDateTime::now_utc(),
643 timezone,
644 time_format::TimestampFormat::EnhancedAbsolute,
645 ),
646 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
647 }
648 }
649}
650
651impl From<TimeBucket> for EntryTimeFormat {
652 fn from(bucket: TimeBucket) -> Self {
653 match bucket {
654 TimeBucket::Today => EntryTimeFormat::TimeOnly,
655 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
656 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
657 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
658 TimeBucket::All => EntryTimeFormat::DateAndTime,
659 }
660 }
661}
662
663#[derive(PartialEq, Eq, Clone, Copy, Debug)]
664enum TimeBucket {
665 Today,
666 Yesterday,
667 ThisWeek,
668 PastWeek,
669 All,
670}
671
672impl TimeBucket {
673 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
674 if date == reference {
675 return TimeBucket::Today;
676 }
677
678 if date == reference - TimeDelta::days(1) {
679 return TimeBucket::Yesterday;
680 }
681
682 let week = date.iso_week();
683
684 if reference.iso_week() == week {
685 return TimeBucket::ThisWeek;
686 }
687
688 let last_week = (reference - TimeDelta::days(7)).iso_week();
689
690 if week == last_week {
691 return TimeBucket::PastWeek;
692 }
693
694 TimeBucket::All
695 }
696}
697
698impl Display for TimeBucket {
699 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700 match self {
701 TimeBucket::Today => write!(f, "Today"),
702 TimeBucket::Yesterday => write!(f, "Yesterday"),
703 TimeBucket::ThisWeek => write!(f, "This Week"),
704 TimeBucket::PastWeek => write!(f, "Past Week"),
705 TimeBucket::All => write!(f, "All"),
706 }
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use chrono::NaiveDate;
714
715 #[test]
716 fn test_time_bucket_from_dates() {
717 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
718
719 let date = today;
720 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
721
722 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
723 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
724
725 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
726 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
727
728 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
729 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
730
731 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
732 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
733
734 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
735 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
736
737 // All: not in this week or last week
738 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
739 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
740
741 // Test year boundary cases
742 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
743
744 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
745 assert_eq!(
746 TimeBucket::from_dates(new_year, date),
747 TimeBucket::Yesterday
748 );
749
750 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
751 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
752 }
753}