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