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