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 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 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 fn has_session_list(&self) -> bool {
250 self.session_list.is_some()
251 }
252
253 pub fn refresh(&mut self, cx: &mut Context<Self>) {
254 self.refresh_sessions(true, cx);
255 }
256
257 pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
258 self.sessions
259 .iter()
260 .find(|entry| &entry.session_id == session_id)
261 .cloned()
262 }
263
264 pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
265 &self.sessions
266 }
267
268 pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
269 self.sessions.iter().take(limit).cloned().collect()
270 }
271
272 pub fn supports_delete(&self) -> bool {
273 self.session_list
274 .as_ref()
275 .map(|sl| sl.supports_delete())
276 .unwrap_or(false)
277 }
278
279 pub(crate) fn delete_session(
280 &self,
281 session_id: &acp::SessionId,
282 cx: &mut App,
283 ) -> Task<anyhow::Result<()>> {
284 if let Some(session_list) = self.session_list.as_ref() {
285 session_list.delete_session(session_id, cx)
286 } else {
287 Task::ready(Ok(()))
288 }
289 }
290
291 fn add_list_separators(
292 &self,
293 entries: Vec<AgentSessionInfo>,
294 cx: &App,
295 ) -> Task<Vec<ListItemType>> {
296 cx.background_spawn(async move {
297 let mut items = Vec::with_capacity(entries.len() + 1);
298 let mut bucket = None;
299 let today = Local::now().naive_local().date();
300
301 for entry in entries.into_iter() {
302 let entry_bucket = entry
303 .updated_at
304 .map(|timestamp| {
305 let entry_date = timestamp.with_timezone(&Local).naive_local().date();
306 TimeBucket::from_dates(today, entry_date)
307 })
308 .unwrap_or(TimeBucket::All);
309
310 if Some(entry_bucket) != bucket {
311 bucket = Some(entry_bucket);
312 items.push(ListItemType::BucketSeparator(entry_bucket));
313 }
314
315 items.push(ListItemType::Entry {
316 entry,
317 format: entry_bucket.into(),
318 });
319 }
320 items
321 })
322 }
323
324 fn filter_search_results(
325 &self,
326 entries: Vec<AgentSessionInfo>,
327 cx: &App,
328 ) -> Task<Vec<ListItemType>> {
329 let query = self.search_query.clone();
330 cx.background_spawn({
331 let executor = cx.background_executor().clone();
332 async move {
333 let mut candidates = Vec::with_capacity(entries.len());
334
335 for (idx, entry) in entries.iter().enumerate() {
336 candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
337 }
338
339 const MAX_MATCHES: usize = 100;
340
341 let matches = fuzzy::match_strings(
342 &candidates,
343 &query,
344 false,
345 true,
346 MAX_MATCHES,
347 &Default::default(),
348 executor,
349 )
350 .await;
351
352 matches
353 .into_iter()
354 .map(|search_match| ListItemType::SearchResult {
355 entry: entries[search_match.candidate_id].clone(),
356 positions: search_match.positions,
357 })
358 .collect()
359 }
360 })
361 }
362
363 fn search_produced_no_matches(&self) -> bool {
364 self.visible_items.is_empty() && !self.search_query.is_empty()
365 }
366
367 fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
368 self.get_history_entry(self.selected_index)
369 }
370
371 fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
372 self.visible_items.get(visible_items_ix)?.history_entry()
373 }
374
375 fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
376 if self.visible_items.len() == 0 {
377 self.selected_index = 0;
378 return;
379 }
380 while matches!(
381 self.visible_items.get(index),
382 None | Some(ListItemType::BucketSeparator(..))
383 ) {
384 index = match bias {
385 Bias::Left => {
386 if index == 0 {
387 self.visible_items.len() - 1
388 } else {
389 index - 1
390 }
391 }
392 Bias::Right => {
393 if index >= self.visible_items.len() - 1 {
394 0
395 } else {
396 index + 1
397 }
398 }
399 };
400 }
401 self.selected_index = index;
402 self.scroll_handle
403 .scroll_to_item(index, ScrollStrategy::Top);
404 cx.notify()
405 }
406
407 pub fn select_previous(
408 &mut self,
409 _: &menu::SelectPrevious,
410 _window: &mut Window,
411 cx: &mut Context<Self>,
412 ) {
413 if self.selected_index == 0 {
414 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
415 } else {
416 self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
417 }
418 }
419
420 pub fn select_next(
421 &mut self,
422 _: &menu::SelectNext,
423 _window: &mut Window,
424 cx: &mut Context<Self>,
425 ) {
426 if self.selected_index == self.visible_items.len() - 1 {
427 self.set_selected_index(0, Bias::Right, cx);
428 } else {
429 self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
430 }
431 }
432
433 fn select_first(
434 &mut self,
435 _: &menu::SelectFirst,
436 _window: &mut Window,
437 cx: &mut Context<Self>,
438 ) {
439 self.set_selected_index(0, Bias::Right, cx);
440 }
441
442 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
443 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
444 }
445
446 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
447 self.confirm_entry(self.selected_index, cx);
448 }
449
450 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
451 let Some(entry) = self.get_history_entry(ix) else {
452 return;
453 };
454 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
455 }
456
457 fn remove_selected_thread(
458 &mut self,
459 _: &RemoveSelectedThread,
460 _window: &mut Window,
461 cx: &mut Context<Self>,
462 ) {
463 self.remove_thread(self.selected_index, cx)
464 }
465
466 fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
467 let Some(entry) = self.get_history_entry(visible_item_ix) else {
468 return;
469 };
470 let Some(session_list) = self.session_list.as_ref() else {
471 return;
472 };
473 if !session_list.supports_delete() {
474 return;
475 }
476 let task = session_list.delete_session(&entry.session_id, cx);
477 task.detach_and_log_err(cx);
478 }
479
480 fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
481 let Some(session_list) = self.session_list.as_ref() else {
482 return;
483 };
484 if !session_list.supports_delete() {
485 return;
486 }
487 session_list.delete_sessions(cx).detach_and_log_err(cx);
488 self.confirming_delete_history = false;
489 cx.notify();
490 }
491
492 fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
493 self.confirming_delete_history = true;
494 cx.notify();
495 }
496
497 fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
498 self.confirming_delete_history = false;
499 cx.notify();
500 }
501
502 fn render_list_items(
503 &mut self,
504 range: Range<usize>,
505 _window: &mut Window,
506 cx: &mut Context<Self>,
507 ) -> Vec<AnyElement> {
508 self.visible_items
509 .get(range.clone())
510 .into_iter()
511 .flatten()
512 .enumerate()
513 .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
514 .collect()
515 }
516
517 fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
518 match item {
519 ListItemType::Entry { entry, format } => self
520 .render_history_entry(entry, *format, ix, Vec::default(), cx)
521 .into_any(),
522 ListItemType::SearchResult { entry, positions } => self.render_history_entry(
523 entry,
524 EntryTimeFormat::DateAndTime,
525 ix,
526 positions.clone(),
527 cx,
528 ),
529 ListItemType::BucketSeparator(bucket) => div()
530 .px(DynamicSpacing::Base06.rems(cx))
531 .pt_2()
532 .pb_1()
533 .child(
534 Label::new(bucket.to_string())
535 .size(LabelSize::XSmall)
536 .color(Color::Muted),
537 )
538 .into_any_element(),
539 }
540 }
541
542 fn render_history_entry(
543 &self,
544 entry: &AgentSessionInfo,
545 format: EntryTimeFormat,
546 ix: usize,
547 highlight_positions: Vec<usize>,
548 cx: &Context<Self>,
549 ) -> AnyElement {
550 let selected = ix == self.selected_index;
551 let hovered = Some(ix) == self.hovered_index;
552 let entry_time = entry.updated_at;
553 let display_text = match (format, entry_time) {
554 (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
555 let now = Utc::now();
556 let duration = now.signed_duration_since(entry_time);
557 let days = duration.num_days();
558
559 format!("{}d", days)
560 }
561 (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
562 format.format_timestamp(entry_time.timestamp(), self.local_timezone)
563 }
564 (_, None) => "—".to_string(),
565 };
566
567 let title = thread_title(entry).clone();
568 let full_date = entry_time
569 .map(|time| {
570 EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
571 })
572 .unwrap_or_else(|| "Unknown".to_string());
573
574 h_flex()
575 .w_full()
576 .pb_1()
577 .child(
578 ListItem::new(ix)
579 .rounded()
580 .toggle_state(selected)
581 .spacing(ListItemSpacing::Sparse)
582 .start_slot(
583 h_flex()
584 .w_full()
585 .gap_2()
586 .justify_between()
587 .child(
588 HighlightedLabel::new(thread_title(entry), highlight_positions)
589 .size(LabelSize::Small)
590 .truncate(),
591 )
592 .child(
593 Label::new(display_text)
594 .color(Color::Muted)
595 .size(LabelSize::XSmall),
596 ),
597 )
598 .tooltip(move |_, cx| {
599 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
600 })
601 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
602 if *is_hovered {
603 this.hovered_index = Some(ix);
604 } else if this.hovered_index == Some(ix) {
605 this.hovered_index = None;
606 }
607
608 cx.notify();
609 }))
610 .end_slot::<IconButton>(if hovered && self.supports_delete() {
611 Some(
612 IconButton::new("delete", IconName::Trash)
613 .shape(IconButtonShape::Square)
614 .icon_size(IconSize::XSmall)
615 .icon_color(Color::Muted)
616 .tooltip(move |_window, cx| {
617 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
618 })
619 .on_click(cx.listener(move |this, _, _, cx| {
620 this.remove_thread(ix, cx);
621 cx.stop_propagation()
622 })),
623 )
624 } else {
625 None
626 })
627 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
628 )
629 .into_any_element()
630 }
631}
632
633impl Focusable for AcpThreadHistory {
634 fn focus_handle(&self, cx: &App) -> FocusHandle {
635 self.search_editor.focus_handle(cx)
636 }
637}
638
639impl Render for AcpThreadHistory {
640 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
641 let has_no_history = self.is_empty();
642
643 v_flex()
644 .key_context("ThreadHistory")
645 .size_full()
646 .bg(cx.theme().colors().panel_background)
647 .on_action(cx.listener(Self::select_previous))
648 .on_action(cx.listener(Self::select_next))
649 .on_action(cx.listener(Self::select_first))
650 .on_action(cx.listener(Self::select_last))
651 .on_action(cx.listener(Self::confirm))
652 .on_action(cx.listener(Self::remove_selected_thread))
653 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
654 this.remove_history(window, cx);
655 }))
656 .child(
657 h_flex()
658 .h(Tab::container_height(cx))
659 .w_full()
660 .py_1()
661 .px_2()
662 .gap_2()
663 .justify_between()
664 .border_b_1()
665 .border_color(cx.theme().colors().border)
666 .child(
667 Icon::new(IconName::MagnifyingGlass)
668 .color(Color::Muted)
669 .size(IconSize::Small),
670 )
671 .child(self.search_editor.clone()),
672 )
673 .child({
674 let view = v_flex()
675 .id("list-container")
676 .relative()
677 .overflow_hidden()
678 .flex_grow();
679
680 if has_no_history {
681 view.justify_center().items_center().child(
682 Label::new("You don't have any past threads yet.")
683 .size(LabelSize::Small)
684 .color(Color::Muted),
685 )
686 } else if self.search_produced_no_matches() {
687 view.justify_center()
688 .items_center()
689 .child(Label::new("No threads match your search.").size(LabelSize::Small))
690 } else {
691 view.child(
692 uniform_list(
693 "thread-history",
694 self.visible_items.len(),
695 cx.processor(|this, range: Range<usize>, window, cx| {
696 this.render_list_items(range, window, cx)
697 }),
698 )
699 .p_1()
700 .pr_4()
701 .track_scroll(&self.scroll_handle)
702 .flex_grow(),
703 )
704 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
705 }
706 })
707 .when(!has_no_history && self.supports_delete(), |this| {
708 this.child(
709 h_flex()
710 .p_2()
711 .border_t_1()
712 .border_color(cx.theme().colors().border_variant)
713 .when(!self.confirming_delete_history, |this| {
714 this.child(
715 Button::new("delete_history", "Delete All History")
716 .full_width()
717 .style(ButtonStyle::Outlined)
718 .label_size(LabelSize::Small)
719 .on_click(cx.listener(|this, _, window, cx| {
720 this.prompt_delete_history(window, cx);
721 })),
722 )
723 })
724 .when(self.confirming_delete_history, |this| {
725 this.w_full()
726 .gap_2()
727 .flex_wrap()
728 .justify_between()
729 .child(
730 h_flex()
731 .flex_wrap()
732 .gap_1()
733 .child(
734 Label::new("Delete all threads?")
735 .size(LabelSize::Small),
736 )
737 .child(
738 Label::new("You won't be able to recover them later.")
739 .size(LabelSize::Small)
740 .color(Color::Muted),
741 ),
742 )
743 .child(
744 h_flex()
745 .gap_1()
746 .child(
747 Button::new("cancel_delete", "Cancel")
748 .label_size(LabelSize::Small)
749 .on_click(cx.listener(|this, _, window, cx| {
750 this.cancel_delete_history(window, cx);
751 })),
752 )
753 .child(
754 Button::new("confirm_delete", "Delete")
755 .style(ButtonStyle::Tinted(ui::TintColor::Error))
756 .color(Color::Error)
757 .label_size(LabelSize::Small)
758 .on_click(cx.listener(|_, _, window, cx| {
759 window.dispatch_action(
760 Box::new(RemoveHistory),
761 cx,
762 );
763 })),
764 ),
765 )
766 }),
767 )
768 })
769 }
770}
771
772#[derive(IntoElement)]
773pub struct AcpHistoryEntryElement {
774 entry: AgentSessionInfo,
775 thread_view: WeakEntity<AcpThreadView>,
776 selected: bool,
777 hovered: bool,
778 supports_delete: bool,
779 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
780}
781
782impl AcpHistoryEntryElement {
783 pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
784 Self {
785 entry,
786 thread_view,
787 selected: false,
788 hovered: false,
789 supports_delete: false,
790 on_hover: Box::new(|_, _, _| {}),
791 }
792 }
793
794 pub fn supports_delete(mut self, supports_delete: bool) -> Self {
795 self.supports_delete = supports_delete;
796 self
797 }
798
799 pub fn hovered(mut self, hovered: bool) -> Self {
800 self.hovered = hovered;
801 self
802 }
803
804 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
805 self.on_hover = Box::new(on_hover);
806 self
807 }
808}
809
810impl RenderOnce for AcpHistoryEntryElement {
811 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
812 let id = ElementId::Name(self.entry.session_id.0.clone().into());
813 let title = thread_title(&self.entry).clone();
814 let formatted_time = self
815 .entry
816 .updated_at
817 .map(|timestamp| {
818 let now = chrono::Utc::now();
819 let duration = now.signed_duration_since(timestamp);
820
821 if duration.num_days() > 0 {
822 format!("{}d", duration.num_days())
823 } else if duration.num_hours() > 0 {
824 format!("{}h ago", duration.num_hours())
825 } else if duration.num_minutes() > 0 {
826 format!("{}m ago", duration.num_minutes())
827 } else {
828 "Just now".to_string()
829 }
830 })
831 .unwrap_or_else(|| "Unknown".to_string());
832
833 ListItem::new(id)
834 .rounded()
835 .toggle_state(self.selected)
836 .spacing(ListItemSpacing::Sparse)
837 .start_slot(
838 h_flex()
839 .w_full()
840 .gap_2()
841 .justify_between()
842 .child(Label::new(title).size(LabelSize::Small).truncate())
843 .child(
844 Label::new(formatted_time)
845 .color(Color::Muted)
846 .size(LabelSize::XSmall),
847 ),
848 )
849 .on_hover(self.on_hover)
850 .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
851 Some(
852 IconButton::new("delete", IconName::Trash)
853 .shape(IconButtonShape::Square)
854 .icon_size(IconSize::XSmall)
855 .icon_color(Color::Muted)
856 .tooltip(move |_window, cx| {
857 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
858 })
859 .on_click({
860 let thread_view = self.thread_view.clone();
861 let entry = self.entry.clone();
862
863 move |_event, _window, cx| {
864 if let Some(thread_view) = thread_view.upgrade() {
865 thread_view.update(cx, |thread_view, cx| {
866 thread_view.delete_history_entry(entry.clone(), cx);
867 });
868 }
869 }
870 }),
871 )
872 } else {
873 None
874 })
875 .on_click({
876 let thread_view = self.thread_view.clone();
877 let entry = self.entry;
878
879 move |_event, window, cx| {
880 if let Some(workspace) = thread_view
881 .upgrade()
882 .and_then(|view| view.read(cx).workspace().upgrade())
883 {
884 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
885 panel.update(cx, |panel, cx| {
886 panel.load_agent_thread(entry.clone(), window, cx);
887 });
888 }
889 }
890 }
891 })
892 }
893}
894
895#[derive(Clone, Copy)]
896pub enum EntryTimeFormat {
897 DateAndTime,
898 TimeOnly,
899}
900
901impl EntryTimeFormat {
902 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
903 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
904
905 match self {
906 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
907 timestamp,
908 OffsetDateTime::now_utc(),
909 timezone,
910 time_format::TimestampFormat::EnhancedAbsolute,
911 ),
912 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
913 }
914 }
915}
916
917impl From<TimeBucket> for EntryTimeFormat {
918 fn from(bucket: TimeBucket) -> Self {
919 match bucket {
920 TimeBucket::Today => EntryTimeFormat::TimeOnly,
921 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
922 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
923 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
924 TimeBucket::All => EntryTimeFormat::DateAndTime,
925 }
926 }
927}
928
929#[derive(PartialEq, Eq, Clone, Copy, Debug)]
930enum TimeBucket {
931 Today,
932 Yesterday,
933 ThisWeek,
934 PastWeek,
935 All,
936}
937
938impl TimeBucket {
939 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
940 if date == reference {
941 return TimeBucket::Today;
942 }
943
944 if date == reference - TimeDelta::days(1) {
945 return TimeBucket::Yesterday;
946 }
947
948 let week = date.iso_week();
949
950 if reference.iso_week() == week {
951 return TimeBucket::ThisWeek;
952 }
953
954 let last_week = (reference - TimeDelta::days(7)).iso_week();
955
956 if week == last_week {
957 return TimeBucket::PastWeek;
958 }
959
960 TimeBucket::All
961 }
962}
963
964impl Display for TimeBucket {
965 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966 match self {
967 TimeBucket::Today => write!(f, "Today"),
968 TimeBucket::Yesterday => write!(f, "Yesterday"),
969 TimeBucket::ThisWeek => write!(f, "This Week"),
970 TimeBucket::PastWeek => write!(f, "Past Week"),
971 TimeBucket::All => write!(f, "All"),
972 }
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use chrono::NaiveDate;
980
981 #[test]
982 fn test_time_bucket_from_dates() {
983 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
984
985 let date = today;
986 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
987
988 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
989 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
990
991 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
992 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
993
994 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
995 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
996
997 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
998 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
999
1000 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1001 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1002
1003 // All: not in this week or last week
1004 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1005 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1006
1007 // Test year boundary cases
1008 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1009
1010 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1011 assert_eq!(
1012 TimeBucket::from_dates(new_year, date),
1013 TimeBucket::Yesterday
1014 );
1015
1016 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1017 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1018 }
1019}