1use crate::acp::AcpThreadView;
2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
3use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest};
4use agent_client_protocol as acp;
5use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
6use editor::{Editor, EditorEvent};
7use fuzzy::StringMatchCandidate;
8use gpui::{
9 App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
10 UniformListScrollHandle, WeakEntity, Window, uniform_list,
11};
12use std::{fmt::Display, ops::Range, rc::Rc};
13use text::Bias;
14use time::{OffsetDateTime, UtcOffset};
15use ui::{
16 ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
17 WithScrollbar, prelude::*,
18};
19
20const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
21
22fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
23 entry
24 .title
25 .as_ref()
26 .filter(|title| !title.is_empty())
27 .unwrap_or(DEFAULT_TITLE)
28}
29
30pub struct AcpThreadHistory {
31 session_list: Option<Rc<dyn AgentSessionList>>,
32 sessions: Vec<AgentSessionInfo>,
33 scroll_handle: UniformListScrollHandle,
34 selected_index: usize,
35 hovered_index: Option<usize>,
36 search_editor: Entity<Editor>,
37 search_query: SharedString,
38 visible_items: Vec<ListItemType>,
39 local_timezone: UtcOffset,
40 confirming_delete_history: bool,
41 _update_task: Task<()>,
42 _watch_task: Option<Task<()>>,
43 _subscriptions: Vec<gpui::Subscription>,
44}
45
46enum ListItemType {
47 BucketSeparator(TimeBucket),
48 Entry {
49 entry: AgentSessionInfo,
50 format: EntryTimeFormat,
51 },
52 SearchResult {
53 entry: AgentSessionInfo,
54 positions: Vec<usize>,
55 },
56}
57
58impl ListItemType {
59 fn history_entry(&self) -> Option<&AgentSessionInfo> {
60 match self {
61 ListItemType::Entry { entry, .. } => Some(entry),
62 ListItemType::SearchResult { entry, .. } => Some(entry),
63 _ => None,
64 }
65 }
66}
67
68pub enum ThreadHistoryEvent {
69 Open(AgentSessionInfo),
70}
71
72impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
73
74impl AcpThreadHistory {
75 pub(crate) fn new(
76 session_list: Option<Rc<dyn AgentSessionList>>,
77 window: &mut Window,
78 cx: &mut Context<Self>,
79 ) -> Self {
80 let search_editor = cx.new(|cx| {
81 let mut editor = Editor::single_line(window, cx);
82 editor.set_placeholder_text("Search threads...", window, cx);
83 editor
84 });
85
86 let search_editor_subscription =
87 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
88 if let EditorEvent::BufferEdited = event {
89 let query = search_editor.read(cx).text(cx);
90 if this.search_query != query {
91 this.search_query = query.into();
92 this.update_visible_items(false, cx);
93 }
94 }
95 });
96
97 let scroll_handle = UniformListScrollHandle::default();
98
99 let mut this = Self {
100 session_list: None,
101 sessions: Vec::new(),
102 scroll_handle,
103 selected_index: 0,
104 hovered_index: None,
105 visible_items: Default::default(),
106 search_editor,
107 local_timezone: UtcOffset::from_whole_seconds(
108 chrono::Local::now().offset().local_minus_utc(),
109 )
110 .unwrap(),
111 search_query: SharedString::default(),
112 confirming_delete_history: false,
113 _subscriptions: vec![search_editor_subscription],
114 _update_task: Task::ready(()),
115 _watch_task: None,
116 };
117 this.set_session_list(session_list, cx);
118 this
119 }
120
121 fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
122 let entries = self.sessions.clone();
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 new_visible_items
139 .iter()
140 .position(|visible_entry| {
141 visible_entry
142 .history_entry()
143 .is_some_and(|entry| entry.session_id == history_entry.session_id)
144 })
145 .unwrap_or(0)
146 } else {
147 0
148 };
149
150 this.visible_items = new_visible_items;
151 this.set_selected_index(new_selected_index, Bias::Right, cx);
152 cx.notify();
153 })
154 .ok();
155 });
156 }
157
158 pub(crate) fn set_session_list(
159 &mut self,
160 session_list: Option<Rc<dyn AgentSessionList>>,
161 cx: &mut Context<Self>,
162 ) {
163 if let (Some(current), Some(next)) = (&self.session_list, &session_list)
164 && Rc::ptr_eq(current, next)
165 {
166 return;
167 }
168
169 self.session_list = session_list;
170 self.sessions.clear();
171 self.visible_items.clear();
172 self.selected_index = 0;
173 self.refresh_sessions(false, cx);
174
175 self._watch_task = self.session_list.as_ref().and_then(|session_list| {
176 let mut rx = session_list.watch(cx)?;
177 Some(cx.spawn(async move |this, cx| {
178 while let Ok(()) = rx.recv().await {
179 this.update(cx, |this, cx| {
180 this.refresh_sessions(true, cx);
181 })
182 .ok();
183 }
184 }))
185 });
186 }
187
188 fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
189 let Some(session_list) = self.session_list.clone() else {
190 self.update_visible_items(preserve_selected_item, cx);
191 return;
192 };
193
194 self._update_task = cx.spawn(async move |this, cx| {
195 let mut cursor: Option<String> = None;
196 let mut is_first_page = true;
197
198 loop {
199 let request = AgentSessionListRequest {
200 cursor: cursor.clone(),
201 ..Default::default()
202 };
203 let task = cx.update(|cx| session_list.list_sessions(request, cx));
204 let response = match task.await {
205 Ok(response) => response,
206 Err(error) => {
207 log::error!("Failed to load session history: {error:#}");
208 return;
209 }
210 };
211
212 let acp_thread::AgentSessionListResponse {
213 sessions: page_sessions,
214 next_cursor,
215 ..
216 } = response;
217
218 this.update(cx, |this, cx| {
219 if is_first_page {
220 this.sessions = page_sessions;
221 } else {
222 this.sessions.extend(page_sessions);
223 }
224 this.update_visible_items(preserve_selected_item, cx);
225 })
226 .ok();
227
228 is_first_page = false;
229 match next_cursor {
230 Some(next_cursor) => {
231 if cursor.as_ref() == Some(&next_cursor) {
232 log::warn!(
233 "Session list pagination returned the same cursor; stopping to avoid a loop."
234 );
235 break;
236 }
237 cursor = Some(next_cursor);
238 }
239 None => break,
240 }
241 }
242 });
243 }
244
245 pub(crate) fn is_empty(&self) -> bool {
246 self.sessions.is_empty()
247 }
248
249 pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
250 self.sessions
251 .iter()
252 .find(|entry| &entry.session_id == session_id)
253 .cloned()
254 }
255
256 pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
257 &self.sessions
258 }
259
260 fn add_list_separators(
261 &self,
262 entries: Vec<AgentSessionInfo>,
263 cx: &App,
264 ) -> Task<Vec<ListItemType>> {
265 cx.background_spawn(async move {
266 let mut items = Vec::with_capacity(entries.len() + 1);
267 let mut bucket = None;
268 let today = Local::now().naive_local().date();
269
270 for entry in entries.into_iter() {
271 let entry_bucket = entry
272 .updated_at
273 .map(|timestamp| {
274 let entry_date = timestamp.with_timezone(&Local).naive_local().date();
275 TimeBucket::from_dates(today, entry_date)
276 })
277 .unwrap_or(TimeBucket::All);
278
279 if Some(entry_bucket) != bucket {
280 bucket = Some(entry_bucket);
281 items.push(ListItemType::BucketSeparator(entry_bucket));
282 }
283
284 items.push(ListItemType::Entry {
285 entry,
286 format: entry_bucket.into(),
287 });
288 }
289 items
290 })
291 }
292
293 fn filter_search_results(
294 &self,
295 entries: Vec<AgentSessionInfo>,
296 cx: &App,
297 ) -> Task<Vec<ListItemType>> {
298 let query = self.search_query.clone();
299 cx.background_spawn({
300 let executor = cx.background_executor().clone();
301 async move {
302 let mut candidates = Vec::with_capacity(entries.len());
303
304 for (idx, entry) in entries.iter().enumerate() {
305 candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
306 }
307
308 const MAX_MATCHES: usize = 100;
309
310 let matches = fuzzy::match_strings(
311 &candidates,
312 &query,
313 false,
314 true,
315 MAX_MATCHES,
316 &Default::default(),
317 executor,
318 )
319 .await;
320
321 matches
322 .into_iter()
323 .map(|search_match| ListItemType::SearchResult {
324 entry: entries[search_match.candidate_id].clone(),
325 positions: search_match.positions,
326 })
327 .collect()
328 }
329 })
330 }
331
332 fn search_produced_no_matches(&self) -> bool {
333 self.visible_items.is_empty() && !self.search_query.is_empty()
334 }
335
336 fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
337 self.get_history_entry(self.selected_index)
338 }
339
340 fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
341 self.visible_items.get(visible_items_ix)?.history_entry()
342 }
343
344 fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
345 if self.visible_items.len() == 0 {
346 self.selected_index = 0;
347 return;
348 }
349 while matches!(
350 self.visible_items.get(index),
351 None | Some(ListItemType::BucketSeparator(..))
352 ) {
353 index = match bias {
354 Bias::Left => {
355 if index == 0 {
356 self.visible_items.len() - 1
357 } else {
358 index - 1
359 }
360 }
361 Bias::Right => {
362 if index >= self.visible_items.len() - 1 {
363 0
364 } else {
365 index + 1
366 }
367 }
368 };
369 }
370 self.selected_index = index;
371 self.scroll_handle
372 .scroll_to_item(index, ScrollStrategy::Top);
373 cx.notify()
374 }
375
376 pub fn select_previous(
377 &mut self,
378 _: &menu::SelectPrevious,
379 _window: &mut Window,
380 cx: &mut Context<Self>,
381 ) {
382 if self.selected_index == 0 {
383 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
384 } else {
385 self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
386 }
387 }
388
389 pub fn select_next(
390 &mut self,
391 _: &menu::SelectNext,
392 _window: &mut Window,
393 cx: &mut Context<Self>,
394 ) {
395 if self.selected_index == self.visible_items.len() - 1 {
396 self.set_selected_index(0, Bias::Right, cx);
397 } else {
398 self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
399 }
400 }
401
402 fn select_first(
403 &mut self,
404 _: &menu::SelectFirst,
405 _window: &mut Window,
406 cx: &mut Context<Self>,
407 ) {
408 self.set_selected_index(0, Bias::Right, cx);
409 }
410
411 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
412 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
413 }
414
415 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
416 self.confirm_entry(self.selected_index, cx);
417 }
418
419 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
420 let Some(entry) = self.get_history_entry(ix) else {
421 return;
422 };
423 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
424 }
425
426 fn remove_selected_thread(
427 &mut self,
428 _: &RemoveSelectedThread,
429 _window: &mut Window,
430 cx: &mut Context<Self>,
431 ) {
432 self.remove_thread(self.selected_index, cx)
433 }
434
435 fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
436 let Some(entry) = self.get_history_entry(visible_item_ix) else {
437 return;
438 };
439 let Some(session_list) = self.session_list.as_ref() else {
440 return;
441 };
442 let task = session_list.delete_session(&entry.session_id, cx);
443 task.detach_and_log_err(cx);
444 }
445
446 fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
447 if let Some(session_list) = self.session_list.as_ref() {
448 session_list.delete_sessions(cx).detach_and_log_err(cx);
449 }
450 self.confirming_delete_history = false;
451 cx.notify();
452 }
453
454 fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
455 self.confirming_delete_history = true;
456 cx.notify();
457 }
458
459 fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
460 self.confirming_delete_history = false;
461 cx.notify();
462 }
463
464 fn render_list_items(
465 &mut self,
466 range: Range<usize>,
467 _window: &mut Window,
468 cx: &mut Context<Self>,
469 ) -> Vec<AnyElement> {
470 self.visible_items
471 .get(range.clone())
472 .into_iter()
473 .flatten()
474 .enumerate()
475 .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
476 .collect()
477 }
478
479 fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
480 match item {
481 ListItemType::Entry { entry, format } => self
482 .render_history_entry(entry, *format, ix, Vec::default(), cx)
483 .into_any(),
484 ListItemType::SearchResult { entry, positions } => self.render_history_entry(
485 entry,
486 EntryTimeFormat::DateAndTime,
487 ix,
488 positions.clone(),
489 cx,
490 ),
491 ListItemType::BucketSeparator(bucket) => div()
492 .px(DynamicSpacing::Base06.rems(cx))
493 .pt_2()
494 .pb_1()
495 .child(
496 Label::new(bucket.to_string())
497 .size(LabelSize::XSmall)
498 .color(Color::Muted),
499 )
500 .into_any_element(),
501 }
502 }
503
504 fn render_history_entry(
505 &self,
506 entry: &AgentSessionInfo,
507 format: EntryTimeFormat,
508 ix: usize,
509 highlight_positions: Vec<usize>,
510 cx: &Context<Self>,
511 ) -> AnyElement {
512 let selected = ix == self.selected_index;
513 let hovered = Some(ix) == self.hovered_index;
514 let entry_time = entry.updated_at;
515 let display_text = match (format, entry_time) {
516 (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
517 let now = Utc::now();
518 let duration = now.signed_duration_since(entry_time);
519 let days = duration.num_days();
520
521 format!("{}d", days)
522 }
523 (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
524 format.format_timestamp(entry_time.timestamp(), self.local_timezone)
525 }
526 (_, None) => "—".to_string(),
527 };
528
529 let title = thread_title(entry).clone();
530 let full_date = entry_time
531 .map(|time| {
532 EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
533 })
534 .unwrap_or_else(|| "Unknown".to_string());
535
536 h_flex()
537 .w_full()
538 .pb_1()
539 .child(
540 ListItem::new(ix)
541 .rounded()
542 .toggle_state(selected)
543 .spacing(ListItemSpacing::Sparse)
544 .start_slot(
545 h_flex()
546 .w_full()
547 .gap_2()
548 .justify_between()
549 .child(
550 HighlightedLabel::new(thread_title(entry), highlight_positions)
551 .size(LabelSize::Small)
552 .truncate(),
553 )
554 .child(
555 Label::new(display_text)
556 .color(Color::Muted)
557 .size(LabelSize::XSmall),
558 ),
559 )
560 .tooltip(move |_, cx| {
561 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
562 })
563 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
564 if *is_hovered {
565 this.hovered_index = Some(ix);
566 } else if this.hovered_index == Some(ix) {
567 this.hovered_index = None;
568 }
569
570 cx.notify();
571 }))
572 .end_slot::<IconButton>(if hovered {
573 Some(
574 IconButton::new("delete", IconName::Trash)
575 .shape(IconButtonShape::Square)
576 .icon_size(IconSize::XSmall)
577 .icon_color(Color::Muted)
578 .tooltip(move |_window, cx| {
579 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
580 })
581 .on_click(cx.listener(move |this, _, _, cx| {
582 this.remove_thread(ix, cx);
583 cx.stop_propagation()
584 })),
585 )
586 } else {
587 None
588 })
589 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
590 )
591 .into_any_element()
592 }
593}
594
595impl Focusable for AcpThreadHistory {
596 fn focus_handle(&self, cx: &App) -> FocusHandle {
597 self.search_editor.focus_handle(cx)
598 }
599}
600
601impl Render for AcpThreadHistory {
602 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
603 let has_no_history = self.is_empty();
604
605 v_flex()
606 .key_context("ThreadHistory")
607 .size_full()
608 .bg(cx.theme().colors().panel_background)
609 .on_action(cx.listener(Self::select_previous))
610 .on_action(cx.listener(Self::select_next))
611 .on_action(cx.listener(Self::select_first))
612 .on_action(cx.listener(Self::select_last))
613 .on_action(cx.listener(Self::confirm))
614 .on_action(cx.listener(Self::remove_selected_thread))
615 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
616 this.remove_history(window, cx);
617 }))
618 .child(
619 h_flex()
620 .h(Tab::container_height(cx))
621 .w_full()
622 .py_1()
623 .px_2()
624 .gap_2()
625 .justify_between()
626 .border_b_1()
627 .border_color(cx.theme().colors().border)
628 .child(
629 Icon::new(IconName::MagnifyingGlass)
630 .color(Color::Muted)
631 .size(IconSize::Small),
632 )
633 .child(self.search_editor.clone()),
634 )
635 .child({
636 let view = v_flex()
637 .id("list-container")
638 .relative()
639 .overflow_hidden()
640 .flex_grow();
641
642 if has_no_history {
643 view.justify_center().items_center().child(
644 Label::new("You don't have any past threads yet.")
645 .size(LabelSize::Small)
646 .color(Color::Muted),
647 )
648 } else if self.search_produced_no_matches() {
649 view.justify_center()
650 .items_center()
651 .child(Label::new("No threads match your search.").size(LabelSize::Small))
652 } else {
653 view.child(
654 uniform_list(
655 "thread-history",
656 self.visible_items.len(),
657 cx.processor(|this, range: Range<usize>, window, cx| {
658 this.render_list_items(range, window, cx)
659 }),
660 )
661 .p_1()
662 .pr_4()
663 .track_scroll(&self.scroll_handle)
664 .flex_grow(),
665 )
666 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
667 }
668 })
669 .when(!has_no_history, |this| {
670 this.child(
671 h_flex()
672 .p_2()
673 .border_t_1()
674 .border_color(cx.theme().colors().border_variant)
675 .when(!self.confirming_delete_history, |this| {
676 this.child(
677 Button::new("delete_history", "Delete All History")
678 .full_width()
679 .style(ButtonStyle::Outlined)
680 .label_size(LabelSize::Small)
681 .on_click(cx.listener(|this, _, window, cx| {
682 this.prompt_delete_history(window, cx);
683 })),
684 )
685 })
686 .when(self.confirming_delete_history, |this| {
687 this.w_full()
688 .gap_2()
689 .flex_wrap()
690 .justify_between()
691 .child(
692 h_flex()
693 .flex_wrap()
694 .gap_1()
695 .child(
696 Label::new("Delete all threads?")
697 .size(LabelSize::Small),
698 )
699 .child(
700 Label::new("You won't be able to recover them later.")
701 .size(LabelSize::Small)
702 .color(Color::Muted),
703 ),
704 )
705 .child(
706 h_flex()
707 .gap_1()
708 .child(
709 Button::new("cancel_delete", "Cancel")
710 .label_size(LabelSize::Small)
711 .on_click(cx.listener(|this, _, window, cx| {
712 this.cancel_delete_history(window, cx);
713 })),
714 )
715 .child(
716 Button::new("confirm_delete", "Delete")
717 .style(ButtonStyle::Tinted(ui::TintColor::Error))
718 .color(Color::Error)
719 .label_size(LabelSize::Small)
720 .on_click(cx.listener(|_, _, window, cx| {
721 window.dispatch_action(
722 Box::new(RemoveHistory),
723 cx,
724 );
725 })),
726 ),
727 )
728 }),
729 )
730 })
731 }
732}
733
734#[derive(IntoElement)]
735pub struct AcpHistoryEntryElement {
736 entry: AgentSessionInfo,
737 thread_view: WeakEntity<AcpThreadView>,
738 selected: bool,
739 hovered: bool,
740 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
741}
742
743impl AcpHistoryEntryElement {
744 pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
745 Self {
746 entry,
747 thread_view,
748 selected: false,
749 hovered: false,
750 on_hover: Box::new(|_, _, _| {}),
751 }
752 }
753
754 pub fn hovered(mut self, hovered: bool) -> Self {
755 self.hovered = hovered;
756 self
757 }
758
759 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
760 self.on_hover = Box::new(on_hover);
761 self
762 }
763}
764
765impl RenderOnce for AcpHistoryEntryElement {
766 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
767 let id = ElementId::Name(self.entry.session_id.0.clone().into());
768 let title = thread_title(&self.entry).clone();
769 let formatted_time = self
770 .entry
771 .updated_at
772 .map(|timestamp| {
773 let now = chrono::Utc::now();
774 let duration = now.signed_duration_since(timestamp);
775
776 if duration.num_days() > 0 {
777 format!("{}d", duration.num_days())
778 } else if duration.num_hours() > 0 {
779 format!("{}h ago", duration.num_hours())
780 } else if duration.num_minutes() > 0 {
781 format!("{}m ago", duration.num_minutes())
782 } else {
783 "Just now".to_string()
784 }
785 })
786 .unwrap_or_else(|| "Unknown".to_string());
787
788 ListItem::new(id)
789 .rounded()
790 .toggle_state(self.selected)
791 .spacing(ListItemSpacing::Sparse)
792 .start_slot(
793 h_flex()
794 .w_full()
795 .gap_2()
796 .justify_between()
797 .child(Label::new(title).size(LabelSize::Small).truncate())
798 .child(
799 Label::new(formatted_time)
800 .color(Color::Muted)
801 .size(LabelSize::XSmall),
802 ),
803 )
804 .on_hover(self.on_hover)
805 .end_slot::<IconButton>(if self.hovered || self.selected {
806 Some(
807 IconButton::new("delete", IconName::Trash)
808 .shape(IconButtonShape::Square)
809 .icon_size(IconSize::XSmall)
810 .icon_color(Color::Muted)
811 .tooltip(move |_window, cx| {
812 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
813 })
814 .on_click({
815 let thread_view = self.thread_view.clone();
816 let entry = self.entry.clone();
817
818 move |_event, _window, cx| {
819 if let Some(thread_view) = thread_view.upgrade() {
820 thread_view.update(cx, |thread_view, cx| {
821 thread_view.delete_history_entry(entry.clone(), cx);
822 });
823 }
824 }
825 }),
826 )
827 } else {
828 None
829 })
830 .on_click({
831 let thread_view = self.thread_view.clone();
832 let entry = self.entry;
833
834 move |_event, window, cx| {
835 if let Some(workspace) = thread_view
836 .upgrade()
837 .and_then(|view| view.read(cx).workspace().upgrade())
838 {
839 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
840 panel.update(cx, |panel, cx| {
841 panel.load_agent_thread(entry.clone(), window, cx);
842 });
843 }
844 }
845 }
846 })
847 }
848}
849
850#[derive(Clone, Copy)]
851pub enum EntryTimeFormat {
852 DateAndTime,
853 TimeOnly,
854}
855
856impl EntryTimeFormat {
857 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
858 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
859
860 match self {
861 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
862 timestamp,
863 OffsetDateTime::now_utc(),
864 timezone,
865 time_format::TimestampFormat::EnhancedAbsolute,
866 ),
867 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
868 }
869 }
870}
871
872impl From<TimeBucket> for EntryTimeFormat {
873 fn from(bucket: TimeBucket) -> Self {
874 match bucket {
875 TimeBucket::Today => EntryTimeFormat::TimeOnly,
876 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
877 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
878 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
879 TimeBucket::All => EntryTimeFormat::DateAndTime,
880 }
881 }
882}
883
884#[derive(PartialEq, Eq, Clone, Copy, Debug)]
885enum TimeBucket {
886 Today,
887 Yesterday,
888 ThisWeek,
889 PastWeek,
890 All,
891}
892
893impl TimeBucket {
894 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
895 if date == reference {
896 return TimeBucket::Today;
897 }
898
899 if date == reference - TimeDelta::days(1) {
900 return TimeBucket::Yesterday;
901 }
902
903 let week = date.iso_week();
904
905 if reference.iso_week() == week {
906 return TimeBucket::ThisWeek;
907 }
908
909 let last_week = (reference - TimeDelta::days(7)).iso_week();
910
911 if week == last_week {
912 return TimeBucket::PastWeek;
913 }
914
915 TimeBucket::All
916 }
917}
918
919impl Display for TimeBucket {
920 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921 match self {
922 TimeBucket::Today => write!(f, "Today"),
923 TimeBucket::Yesterday => write!(f, "Yesterday"),
924 TimeBucket::ThisWeek => write!(f, "This Week"),
925 TimeBucket::PastWeek => write!(f, "Past Week"),
926 TimeBucket::All => write!(f, "All"),
927 }
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use chrono::NaiveDate;
935
936 #[test]
937 fn test_time_bucket_from_dates() {
938 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
939
940 let date = today;
941 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
942
943 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
944 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
945
946 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
947 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
948
949 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
950 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
951
952 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
953 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
954
955 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
956 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
957
958 // All: not in this week or last week
959 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
960 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
961
962 // Test year boundary cases
963 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
964
965 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
966 assert_eq!(
967 TimeBucket::from_dates(new_year, date),
968 TimeBucket::Yesterday
969 );
970
971 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
972 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
973 }
974}