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}