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}