1use crate::acp::AcpThreadView;
2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
3use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
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 rx = session_list.watch(cx)?;
177 Some(cx.spawn(async move |this, cx| {
178 while let Ok(first_update) = rx.recv().await {
179 let mut updates = vec![first_update];
180 while let Ok(update) = rx.try_recv() {
181 updates.push(update);
182 }
183
184 let needs_refresh = updates
185 .iter()
186 .any(|u| matches!(u, SessionListUpdate::Refresh));
187
188 this.update(cx, |this, cx| {
189 // We will refresh the whole list anyway, so no need to apply incremental updates or do several refreshes
190 if needs_refresh {
191 this.refresh_sessions(true, cx);
192 } else {
193 for update in updates {
194 if let SessionListUpdate::SessionInfo { session_id, update } =
195 update
196 {
197 this.apply_info_update(session_id, update, cx);
198 }
199 }
200 }
201 })
202 .ok();
203 }
204 }))
205 });
206 }
207
208 fn apply_info_update(
209 &mut self,
210 session_id: acp::SessionId,
211 info_update: acp::SessionInfoUpdate,
212 cx: &mut Context<Self>,
213 ) {
214 let Some(session) = self
215 .sessions
216 .iter_mut()
217 .find(|s| s.session_id == session_id)
218 else {
219 return;
220 };
221
222 match info_update.title {
223 acp::MaybeUndefined::Value(title) => {
224 session.title = Some(title.into());
225 }
226 acp::MaybeUndefined::Null => {
227 session.title = None;
228 }
229 acp::MaybeUndefined::Undefined => {}
230 }
231 match info_update.updated_at {
232 acp::MaybeUndefined::Value(date_str) => {
233 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
234 session.updated_at = Some(dt.with_timezone(&chrono::Utc));
235 }
236 }
237 acp::MaybeUndefined::Null => {
238 session.updated_at = None;
239 }
240 acp::MaybeUndefined::Undefined => {}
241 }
242 if let Some(meta) = info_update.meta {
243 session.meta = Some(meta);
244 }
245
246 self.update_visible_items(true, cx);
247 }
248
249 fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
250 let Some(session_list) = self.session_list.clone() else {
251 self.update_visible_items(preserve_selected_item, cx);
252 return;
253 };
254
255 self._update_task = cx.spawn(async move |this, cx| {
256 let mut cursor: Option<String> = None;
257 let mut is_first_page = true;
258
259 loop {
260 let request = AgentSessionListRequest {
261 cursor: cursor.clone(),
262 ..Default::default()
263 };
264 let task = cx.update(|cx| session_list.list_sessions(request, cx));
265 let response = match task.await {
266 Ok(response) => response,
267 Err(error) => {
268 log::error!("Failed to load session history: {error:#}");
269 return;
270 }
271 };
272
273 let acp_thread::AgentSessionListResponse {
274 sessions: page_sessions,
275 next_cursor,
276 ..
277 } = response;
278
279 this.update(cx, |this, cx| {
280 if is_first_page {
281 this.sessions = page_sessions;
282 } else {
283 this.sessions.extend(page_sessions);
284 }
285 this.update_visible_items(preserve_selected_item, cx);
286 })
287 .ok();
288
289 is_first_page = false;
290 match next_cursor {
291 Some(next_cursor) => {
292 if cursor.as_ref() == Some(&next_cursor) {
293 log::warn!(
294 "Session list pagination returned the same cursor; stopping to avoid a loop."
295 );
296 break;
297 }
298 cursor = Some(next_cursor);
299 }
300 None => break,
301 }
302 }
303 });
304 }
305
306 pub(crate) fn is_empty(&self) -> bool {
307 self.sessions.is_empty()
308 }
309
310 pub fn has_session_list(&self) -> bool {
311 self.session_list.is_some()
312 }
313
314 pub fn refresh(&mut self, cx: &mut Context<Self>) {
315 self.refresh_sessions(true, cx);
316 }
317
318 pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
319 self.sessions
320 .iter()
321 .find(|entry| &entry.session_id == session_id)
322 .cloned()
323 }
324
325 pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
326 &self.sessions
327 }
328
329 pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
330 self.sessions.iter().take(limit).cloned().collect()
331 }
332
333 pub fn supports_delete(&self) -> bool {
334 self.session_list
335 .as_ref()
336 .map(|sl| sl.supports_delete())
337 .unwrap_or(false)
338 }
339
340 pub(crate) fn delete_session(
341 &self,
342 session_id: &acp::SessionId,
343 cx: &mut App,
344 ) -> Task<anyhow::Result<()>> {
345 if let Some(session_list) = self.session_list.as_ref() {
346 session_list.delete_session(session_id, cx)
347 } else {
348 Task::ready(Ok(()))
349 }
350 }
351
352 fn add_list_separators(
353 &self,
354 entries: Vec<AgentSessionInfo>,
355 cx: &App,
356 ) -> Task<Vec<ListItemType>> {
357 cx.background_spawn(async move {
358 let mut items = Vec::with_capacity(entries.len() + 1);
359 let mut bucket = None;
360 let today = Local::now().naive_local().date();
361
362 for entry in entries.into_iter() {
363 let entry_bucket = entry
364 .updated_at
365 .map(|timestamp| {
366 let entry_date = timestamp.with_timezone(&Local).naive_local().date();
367 TimeBucket::from_dates(today, entry_date)
368 })
369 .unwrap_or(TimeBucket::All);
370
371 if Some(entry_bucket) != bucket {
372 bucket = Some(entry_bucket);
373 items.push(ListItemType::BucketSeparator(entry_bucket));
374 }
375
376 items.push(ListItemType::Entry {
377 entry,
378 format: entry_bucket.into(),
379 });
380 }
381 items
382 })
383 }
384
385 fn filter_search_results(
386 &self,
387 entries: Vec<AgentSessionInfo>,
388 cx: &App,
389 ) -> Task<Vec<ListItemType>> {
390 let query = self.search_query.clone();
391 cx.background_spawn({
392 let executor = cx.background_executor().clone();
393 async move {
394 let mut candidates = Vec::with_capacity(entries.len());
395
396 for (idx, entry) in entries.iter().enumerate() {
397 candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
398 }
399
400 const MAX_MATCHES: usize = 100;
401
402 let matches = fuzzy::match_strings(
403 &candidates,
404 &query,
405 false,
406 true,
407 MAX_MATCHES,
408 &Default::default(),
409 executor,
410 )
411 .await;
412
413 matches
414 .into_iter()
415 .map(|search_match| ListItemType::SearchResult {
416 entry: entries[search_match.candidate_id].clone(),
417 positions: search_match.positions,
418 })
419 .collect()
420 }
421 })
422 }
423
424 fn search_produced_no_matches(&self) -> bool {
425 self.visible_items.is_empty() && !self.search_query.is_empty()
426 }
427
428 fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
429 self.get_history_entry(self.selected_index)
430 }
431
432 fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
433 self.visible_items.get(visible_items_ix)?.history_entry()
434 }
435
436 fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
437 if self.visible_items.len() == 0 {
438 self.selected_index = 0;
439 return;
440 }
441 while matches!(
442 self.visible_items.get(index),
443 None | Some(ListItemType::BucketSeparator(..))
444 ) {
445 index = match bias {
446 Bias::Left => {
447 if index == 0 {
448 self.visible_items.len() - 1
449 } else {
450 index - 1
451 }
452 }
453 Bias::Right => {
454 if index >= self.visible_items.len() - 1 {
455 0
456 } else {
457 index + 1
458 }
459 }
460 };
461 }
462 self.selected_index = index;
463 self.scroll_handle
464 .scroll_to_item(index, ScrollStrategy::Top);
465 cx.notify()
466 }
467
468 pub fn select_previous(
469 &mut self,
470 _: &menu::SelectPrevious,
471 _window: &mut Window,
472 cx: &mut Context<Self>,
473 ) {
474 if self.selected_index == 0 {
475 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
476 } else {
477 self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
478 }
479 }
480
481 pub fn select_next(
482 &mut self,
483 _: &menu::SelectNext,
484 _window: &mut Window,
485 cx: &mut Context<Self>,
486 ) {
487 if self.selected_index == self.visible_items.len() - 1 {
488 self.set_selected_index(0, Bias::Right, cx);
489 } else {
490 self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
491 }
492 }
493
494 fn select_first(
495 &mut self,
496 _: &menu::SelectFirst,
497 _window: &mut Window,
498 cx: &mut Context<Self>,
499 ) {
500 self.set_selected_index(0, Bias::Right, cx);
501 }
502
503 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
504 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
505 }
506
507 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
508 self.confirm_entry(self.selected_index, cx);
509 }
510
511 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
512 let Some(entry) = self.get_history_entry(ix) else {
513 return;
514 };
515 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
516 }
517
518 fn remove_selected_thread(
519 &mut self,
520 _: &RemoveSelectedThread,
521 _window: &mut Window,
522 cx: &mut Context<Self>,
523 ) {
524 self.remove_thread(self.selected_index, cx)
525 }
526
527 fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
528 let Some(entry) = self.get_history_entry(visible_item_ix) else {
529 return;
530 };
531 let Some(session_list) = self.session_list.as_ref() else {
532 return;
533 };
534 if !session_list.supports_delete() {
535 return;
536 }
537 let task = session_list.delete_session(&entry.session_id, cx);
538 task.detach_and_log_err(cx);
539 }
540
541 fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
542 let Some(session_list) = self.session_list.as_ref() else {
543 return;
544 };
545 if !session_list.supports_delete() {
546 return;
547 }
548 session_list.delete_sessions(cx).detach_and_log_err(cx);
549 self.confirming_delete_history = false;
550 cx.notify();
551 }
552
553 fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
554 self.confirming_delete_history = true;
555 cx.notify();
556 }
557
558 fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
559 self.confirming_delete_history = false;
560 cx.notify();
561 }
562
563 fn render_list_items(
564 &mut self,
565 range: Range<usize>,
566 _window: &mut Window,
567 cx: &mut Context<Self>,
568 ) -> Vec<AnyElement> {
569 self.visible_items
570 .get(range.clone())
571 .into_iter()
572 .flatten()
573 .enumerate()
574 .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
575 .collect()
576 }
577
578 fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
579 match item {
580 ListItemType::Entry { entry, format } => self
581 .render_history_entry(entry, *format, ix, Vec::default(), cx)
582 .into_any(),
583 ListItemType::SearchResult { entry, positions } => self.render_history_entry(
584 entry,
585 EntryTimeFormat::DateAndTime,
586 ix,
587 positions.clone(),
588 cx,
589 ),
590 ListItemType::BucketSeparator(bucket) => div()
591 .px(DynamicSpacing::Base06.rems(cx))
592 .pt_2()
593 .pb_1()
594 .child(
595 Label::new(bucket.to_string())
596 .size(LabelSize::XSmall)
597 .color(Color::Muted),
598 )
599 .into_any_element(),
600 }
601 }
602
603 fn render_history_entry(
604 &self,
605 entry: &AgentSessionInfo,
606 format: EntryTimeFormat,
607 ix: usize,
608 highlight_positions: Vec<usize>,
609 cx: &Context<Self>,
610 ) -> AnyElement {
611 let selected = ix == self.selected_index;
612 let hovered = Some(ix) == self.hovered_index;
613 let entry_time = entry.updated_at;
614 let display_text = match (format, entry_time) {
615 (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
616 let now = Utc::now();
617 let duration = now.signed_duration_since(entry_time);
618 let days = duration.num_days();
619
620 format!("{}d", days)
621 }
622 (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
623 format.format_timestamp(entry_time.timestamp(), self.local_timezone)
624 }
625 (_, None) => "—".to_string(),
626 };
627
628 let title = thread_title(entry).clone();
629 let full_date = entry_time
630 .map(|time| {
631 EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
632 })
633 .unwrap_or_else(|| "Unknown".to_string());
634
635 h_flex()
636 .w_full()
637 .pb_1()
638 .child(
639 ListItem::new(ix)
640 .rounded()
641 .toggle_state(selected)
642 .spacing(ListItemSpacing::Sparse)
643 .start_slot(
644 h_flex()
645 .w_full()
646 .gap_2()
647 .justify_between()
648 .child(
649 HighlightedLabel::new(thread_title(entry), highlight_positions)
650 .size(LabelSize::Small)
651 .truncate(),
652 )
653 .child(
654 Label::new(display_text)
655 .color(Color::Muted)
656 .size(LabelSize::XSmall),
657 ),
658 )
659 .tooltip(move |_, cx| {
660 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
661 })
662 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
663 if *is_hovered {
664 this.hovered_index = Some(ix);
665 } else if this.hovered_index == Some(ix) {
666 this.hovered_index = None;
667 }
668
669 cx.notify();
670 }))
671 .end_slot::<IconButton>(if hovered && self.supports_delete() {
672 Some(
673 IconButton::new("delete", IconName::Trash)
674 .shape(IconButtonShape::Square)
675 .icon_size(IconSize::XSmall)
676 .icon_color(Color::Muted)
677 .tooltip(move |_window, cx| {
678 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
679 })
680 .on_click(cx.listener(move |this, _, _, cx| {
681 this.remove_thread(ix, cx);
682 cx.stop_propagation()
683 })),
684 )
685 } else {
686 None
687 })
688 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
689 )
690 .into_any_element()
691 }
692}
693
694impl Focusable for AcpThreadHistory {
695 fn focus_handle(&self, cx: &App) -> FocusHandle {
696 self.search_editor.focus_handle(cx)
697 }
698}
699
700impl Render for AcpThreadHistory {
701 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
702 let has_no_history = self.is_empty();
703
704 v_flex()
705 .key_context("ThreadHistory")
706 .size_full()
707 .bg(cx.theme().colors().panel_background)
708 .on_action(cx.listener(Self::select_previous))
709 .on_action(cx.listener(Self::select_next))
710 .on_action(cx.listener(Self::select_first))
711 .on_action(cx.listener(Self::select_last))
712 .on_action(cx.listener(Self::confirm))
713 .on_action(cx.listener(Self::remove_selected_thread))
714 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
715 this.remove_history(window, cx);
716 }))
717 .child(
718 h_flex()
719 .h(Tab::container_height(cx))
720 .w_full()
721 .py_1()
722 .px_2()
723 .gap_2()
724 .justify_between()
725 .border_b_1()
726 .border_color(cx.theme().colors().border)
727 .child(
728 Icon::new(IconName::MagnifyingGlass)
729 .color(Color::Muted)
730 .size(IconSize::Small),
731 )
732 .child(self.search_editor.clone()),
733 )
734 .child({
735 let view = v_flex()
736 .id("list-container")
737 .relative()
738 .overflow_hidden()
739 .flex_grow();
740
741 if has_no_history {
742 view.justify_center().items_center().child(
743 Label::new("You don't have any past threads yet.")
744 .size(LabelSize::Small)
745 .color(Color::Muted),
746 )
747 } else if self.search_produced_no_matches() {
748 view.justify_center()
749 .items_center()
750 .child(Label::new("No threads match your search.").size(LabelSize::Small))
751 } else {
752 view.child(
753 uniform_list(
754 "thread-history",
755 self.visible_items.len(),
756 cx.processor(|this, range: Range<usize>, window, cx| {
757 this.render_list_items(range, window, cx)
758 }),
759 )
760 .p_1()
761 .pr_4()
762 .track_scroll(&self.scroll_handle)
763 .flex_grow(),
764 )
765 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
766 }
767 })
768 .when(!has_no_history && self.supports_delete(), |this| {
769 this.child(
770 h_flex()
771 .p_2()
772 .border_t_1()
773 .border_color(cx.theme().colors().border_variant)
774 .when(!self.confirming_delete_history, |this| {
775 this.child(
776 Button::new("delete_history", "Delete All History")
777 .full_width()
778 .style(ButtonStyle::Outlined)
779 .label_size(LabelSize::Small)
780 .on_click(cx.listener(|this, _, window, cx| {
781 this.prompt_delete_history(window, cx);
782 })),
783 )
784 })
785 .when(self.confirming_delete_history, |this| {
786 this.w_full()
787 .gap_2()
788 .flex_wrap()
789 .justify_between()
790 .child(
791 h_flex()
792 .flex_wrap()
793 .gap_1()
794 .child(
795 Label::new("Delete all threads?")
796 .size(LabelSize::Small),
797 )
798 .child(
799 Label::new("You won't be able to recover them later.")
800 .size(LabelSize::Small)
801 .color(Color::Muted),
802 ),
803 )
804 .child(
805 h_flex()
806 .gap_1()
807 .child(
808 Button::new("cancel_delete", "Cancel")
809 .label_size(LabelSize::Small)
810 .on_click(cx.listener(|this, _, window, cx| {
811 this.cancel_delete_history(window, cx);
812 })),
813 )
814 .child(
815 Button::new("confirm_delete", "Delete")
816 .style(ButtonStyle::Tinted(ui::TintColor::Error))
817 .color(Color::Error)
818 .label_size(LabelSize::Small)
819 .on_click(cx.listener(|_, _, window, cx| {
820 window.dispatch_action(
821 Box::new(RemoveHistory),
822 cx,
823 );
824 })),
825 ),
826 )
827 }),
828 )
829 })
830 }
831}
832
833#[derive(IntoElement)]
834pub struct AcpHistoryEntryElement {
835 entry: AgentSessionInfo,
836 thread_view: WeakEntity<AcpThreadView>,
837 selected: bool,
838 hovered: bool,
839 supports_delete: bool,
840 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
841}
842
843impl AcpHistoryEntryElement {
844 pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> Self {
845 Self {
846 entry,
847 thread_view,
848 selected: false,
849 hovered: false,
850 supports_delete: false,
851 on_hover: Box::new(|_, _, _| {}),
852 }
853 }
854
855 pub fn supports_delete(mut self, supports_delete: bool) -> Self {
856 self.supports_delete = supports_delete;
857 self
858 }
859
860 pub fn hovered(mut self, hovered: bool) -> Self {
861 self.hovered = hovered;
862 self
863 }
864
865 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
866 self.on_hover = Box::new(on_hover);
867 self
868 }
869}
870
871impl RenderOnce for AcpHistoryEntryElement {
872 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
873 let id = ElementId::Name(self.entry.session_id.0.clone().into());
874 let title = thread_title(&self.entry).clone();
875 let formatted_time = self
876 .entry
877 .updated_at
878 .map(|timestamp| {
879 let now = chrono::Utc::now();
880 let duration = now.signed_duration_since(timestamp);
881
882 if duration.num_days() > 0 {
883 format!("{}d", duration.num_days())
884 } else if duration.num_hours() > 0 {
885 format!("{}h ago", duration.num_hours())
886 } else if duration.num_minutes() > 0 {
887 format!("{}m ago", duration.num_minutes())
888 } else {
889 "Just now".to_string()
890 }
891 })
892 .unwrap_or_else(|| "Unknown".to_string());
893
894 ListItem::new(id)
895 .rounded()
896 .toggle_state(self.selected)
897 .spacing(ListItemSpacing::Sparse)
898 .start_slot(
899 h_flex()
900 .w_full()
901 .gap_2()
902 .justify_between()
903 .child(Label::new(title).size(LabelSize::Small).truncate())
904 .child(
905 Label::new(formatted_time)
906 .color(Color::Muted)
907 .size(LabelSize::XSmall),
908 ),
909 )
910 .on_hover(self.on_hover)
911 .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
912 Some(
913 IconButton::new("delete", IconName::Trash)
914 .shape(IconButtonShape::Square)
915 .icon_size(IconSize::XSmall)
916 .icon_color(Color::Muted)
917 .tooltip(move |_window, cx| {
918 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
919 })
920 .on_click({
921 let thread_view = self.thread_view.clone();
922 let entry = self.entry.clone();
923
924 move |_event, _window, cx| {
925 if let Some(thread_view) = thread_view.upgrade() {
926 thread_view.update(cx, |thread_view, cx| {
927 thread_view.delete_history_entry(entry.clone(), cx);
928 });
929 }
930 }
931 }),
932 )
933 } else {
934 None
935 })
936 .on_click({
937 let thread_view = self.thread_view.clone();
938 let entry = self.entry;
939
940 move |_event, window, cx| {
941 if let Some(workspace) = thread_view
942 .upgrade()
943 .and_then(|view| view.read(cx).workspace().upgrade())
944 {
945 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
946 panel.update(cx, |panel, cx| {
947 panel.load_agent_thread(entry.clone(), window, cx);
948 });
949 }
950 }
951 }
952 })
953 }
954}
955
956#[derive(Clone, Copy)]
957pub enum EntryTimeFormat {
958 DateAndTime,
959 TimeOnly,
960}
961
962impl EntryTimeFormat {
963 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
964 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
965
966 match self {
967 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
968 timestamp,
969 OffsetDateTime::now_utc(),
970 timezone,
971 time_format::TimestampFormat::EnhancedAbsolute,
972 ),
973 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
974 }
975 }
976}
977
978impl From<TimeBucket> for EntryTimeFormat {
979 fn from(bucket: TimeBucket) -> Self {
980 match bucket {
981 TimeBucket::Today => EntryTimeFormat::TimeOnly,
982 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
983 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
984 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
985 TimeBucket::All => EntryTimeFormat::DateAndTime,
986 }
987 }
988}
989
990#[derive(PartialEq, Eq, Clone, Copy, Debug)]
991enum TimeBucket {
992 Today,
993 Yesterday,
994 ThisWeek,
995 PastWeek,
996 All,
997}
998
999impl TimeBucket {
1000 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1001 if date == reference {
1002 return TimeBucket::Today;
1003 }
1004
1005 if date == reference - TimeDelta::days(1) {
1006 return TimeBucket::Yesterday;
1007 }
1008
1009 let week = date.iso_week();
1010
1011 if reference.iso_week() == week {
1012 return TimeBucket::ThisWeek;
1013 }
1014
1015 let last_week = (reference - TimeDelta::days(7)).iso_week();
1016
1017 if week == last_week {
1018 return TimeBucket::PastWeek;
1019 }
1020
1021 TimeBucket::All
1022 }
1023}
1024
1025impl Display for TimeBucket {
1026 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027 match self {
1028 TimeBucket::Today => write!(f, "Today"),
1029 TimeBucket::Yesterday => write!(f, "Yesterday"),
1030 TimeBucket::ThisWeek => write!(f, "This Week"),
1031 TimeBucket::PastWeek => write!(f, "Past Week"),
1032 TimeBucket::All => write!(f, "All"),
1033 }
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040 use acp_thread::AgentSessionListResponse;
1041 use chrono::NaiveDate;
1042 use gpui::TestAppContext;
1043 use std::any::Any;
1044
1045 fn init_test(cx: &mut TestAppContext) {
1046 cx.update(|cx| {
1047 let settings_store = settings::SettingsStore::test(cx);
1048 cx.set_global(settings_store);
1049 theme::init(theme::LoadThemes::JustBase, cx);
1050 });
1051 }
1052
1053 #[derive(Clone)]
1054 struct TestSessionList {
1055 sessions: Vec<AgentSessionInfo>,
1056 updates_tx: smol::channel::Sender<SessionListUpdate>,
1057 updates_rx: smol::channel::Receiver<SessionListUpdate>,
1058 }
1059
1060 impl TestSessionList {
1061 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1062 let (tx, rx) = smol::channel::unbounded();
1063 Self {
1064 sessions,
1065 updates_tx: tx,
1066 updates_rx: rx,
1067 }
1068 }
1069
1070 fn send_update(&self, update: SessionListUpdate) {
1071 self.updates_tx.try_send(update).ok();
1072 }
1073 }
1074
1075 impl AgentSessionList for TestSessionList {
1076 fn list_sessions(
1077 &self,
1078 _request: AgentSessionListRequest,
1079 _cx: &mut App,
1080 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1081 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1082 }
1083
1084 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1085 Some(self.updates_rx.clone())
1086 }
1087
1088 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1089 self
1090 }
1091 }
1092
1093 #[gpui::test]
1094 async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1095 init_test(cx);
1096
1097 let session_id = acp::SessionId::new("test-session");
1098 let sessions = vec![AgentSessionInfo {
1099 session_id: session_id.clone(),
1100 cwd: None,
1101 title: Some("Original Title".into()),
1102 updated_at: None,
1103 meta: None,
1104 }];
1105 let session_list = Rc::new(TestSessionList::new(sessions));
1106
1107 let (history, cx) = cx.add_window_view(|window, cx| {
1108 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1109 });
1110 cx.run_until_parked();
1111
1112 // Send a title update
1113 session_list.send_update(SessionListUpdate::SessionInfo {
1114 session_id: session_id.clone(),
1115 update: acp::SessionInfoUpdate::new().title("New Title"),
1116 });
1117 cx.run_until_parked();
1118
1119 // Check that the title was updated
1120 history.update(cx, |history, _cx| {
1121 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1122 assert_eq!(
1123 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1124 Some("New Title")
1125 );
1126 });
1127 }
1128
1129 #[gpui::test]
1130 async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1131 init_test(cx);
1132
1133 let session_id = acp::SessionId::new("test-session");
1134 let sessions = vec![AgentSessionInfo {
1135 session_id: session_id.clone(),
1136 cwd: None,
1137 title: Some("Original Title".into()),
1138 updated_at: None,
1139 meta: None,
1140 }];
1141 let session_list = Rc::new(TestSessionList::new(sessions));
1142
1143 let (history, cx) = cx.add_window_view(|window, cx| {
1144 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1145 });
1146 cx.run_until_parked();
1147
1148 // Send an update that clears the title (null)
1149 session_list.send_update(SessionListUpdate::SessionInfo {
1150 session_id: session_id.clone(),
1151 update: acp::SessionInfoUpdate::new().title(None::<String>),
1152 });
1153 cx.run_until_parked();
1154
1155 // Check that the title was cleared
1156 history.update(cx, |history, _cx| {
1157 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1158 assert_eq!(session.unwrap().title, None);
1159 });
1160 }
1161
1162 #[gpui::test]
1163 async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1164 init_test(cx);
1165
1166 let session_id = acp::SessionId::new("test-session");
1167 let sessions = vec![AgentSessionInfo {
1168 session_id: session_id.clone(),
1169 cwd: None,
1170 title: Some("Original Title".into()),
1171 updated_at: None,
1172 meta: None,
1173 }];
1174 let session_list = Rc::new(TestSessionList::new(sessions));
1175
1176 let (history, cx) = cx.add_window_view(|window, cx| {
1177 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1178 });
1179 cx.run_until_parked();
1180
1181 // Send an update with no fields set (all undefined)
1182 session_list.send_update(SessionListUpdate::SessionInfo {
1183 session_id: session_id.clone(),
1184 update: acp::SessionInfoUpdate::new(),
1185 });
1186 cx.run_until_parked();
1187
1188 // Check that the title is unchanged
1189 history.update(cx, |history, _cx| {
1190 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1191 assert_eq!(
1192 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1193 Some("Original Title")
1194 );
1195 });
1196 }
1197
1198 #[gpui::test]
1199 async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1200 init_test(cx);
1201
1202 let session_id = acp::SessionId::new("test-session");
1203 let sessions = vec![AgentSessionInfo {
1204 session_id: session_id.clone(),
1205 cwd: None,
1206 title: None,
1207 updated_at: None,
1208 meta: None,
1209 }];
1210 let session_list = Rc::new(TestSessionList::new(sessions));
1211
1212 let (history, cx) = cx.add_window_view(|window, cx| {
1213 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1214 });
1215 cx.run_until_parked();
1216
1217 // Send multiple updates before the executor runs
1218 session_list.send_update(SessionListUpdate::SessionInfo {
1219 session_id: session_id.clone(),
1220 update: acp::SessionInfoUpdate::new().title("First Title"),
1221 });
1222 session_list.send_update(SessionListUpdate::SessionInfo {
1223 session_id: session_id.clone(),
1224 update: acp::SessionInfoUpdate::new().title("Second Title"),
1225 });
1226 cx.run_until_parked();
1227
1228 // Check that the final title is "Second Title" (both applied in order)
1229 history.update(cx, |history, _cx| {
1230 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1231 assert_eq!(
1232 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1233 Some("Second Title")
1234 );
1235 });
1236 }
1237
1238 #[gpui::test]
1239 async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1240 init_test(cx);
1241
1242 let session_id = acp::SessionId::new("test-session");
1243 let sessions = vec![AgentSessionInfo {
1244 session_id: session_id.clone(),
1245 cwd: None,
1246 title: Some("Server Title".into()),
1247 updated_at: None,
1248 meta: None,
1249 }];
1250 let session_list = Rc::new(TestSessionList::new(sessions));
1251
1252 let (history, cx) = cx.add_window_view(|window, cx| {
1253 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1254 });
1255 cx.run_until_parked();
1256
1257 // Send an info update followed by a refresh
1258 session_list.send_update(SessionListUpdate::SessionInfo {
1259 session_id: session_id.clone(),
1260 update: acp::SessionInfoUpdate::new().title("Local Update"),
1261 });
1262 session_list.send_update(SessionListUpdate::Refresh);
1263 cx.run_until_parked();
1264
1265 // The refresh should have fetched from server, getting "Server Title"
1266 history.update(cx, |history, _cx| {
1267 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1268 assert_eq!(
1269 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1270 Some("Server Title")
1271 );
1272 });
1273 }
1274
1275 #[gpui::test]
1276 async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1277 init_test(cx);
1278
1279 let session_id = acp::SessionId::new("known-session");
1280 let sessions = vec![AgentSessionInfo {
1281 session_id,
1282 cwd: None,
1283 title: Some("Original".into()),
1284 updated_at: None,
1285 meta: None,
1286 }];
1287 let session_list = Rc::new(TestSessionList::new(sessions));
1288
1289 let (history, cx) = cx.add_window_view(|window, cx| {
1290 AcpThreadHistory::new(Some(session_list.clone()), window, cx)
1291 });
1292 cx.run_until_parked();
1293
1294 // Send an update for an unknown session
1295 session_list.send_update(SessionListUpdate::SessionInfo {
1296 session_id: acp::SessionId::new("unknown-session"),
1297 update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1298 });
1299 cx.run_until_parked();
1300
1301 // Check that the known session is unchanged and no crash occurred
1302 history.update(cx, |history, _cx| {
1303 assert_eq!(history.sessions.len(), 1);
1304 assert_eq!(
1305 history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1306 Some("Original")
1307 );
1308 });
1309 }
1310
1311 #[test]
1312 fn test_time_bucket_from_dates() {
1313 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1314
1315 let date = today;
1316 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1317
1318 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1319 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1320
1321 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1322 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1323
1324 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1325 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1326
1327 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1328 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1329
1330 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1331 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1332
1333 // All: not in this week or last week
1334 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1335 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1336
1337 // Test year boundary cases
1338 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1339
1340 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1341 assert_eq!(
1342 TimeBucket::from_dates(new_year, date),
1343 TimeBucket::Yesterday
1344 );
1345
1346 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1347 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1348 }
1349}