1use crate::ConnectionView;
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 ThreadHistory {
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 _visible_items_task: Task<()>,
42 _refresh_task: Task<()>,
43 _watch_task: Option<Task<()>>,
44 _subscriptions: Vec<gpui::Subscription>,
45}
46
47enum ListItemType {
48 BucketSeparator(TimeBucket),
49 Entry {
50 entry: AgentSessionInfo,
51 format: EntryTimeFormat,
52 },
53 SearchResult {
54 entry: AgentSessionInfo,
55 positions: Vec<usize>,
56 },
57}
58
59impl ListItemType {
60 fn history_entry(&self) -> Option<&AgentSessionInfo> {
61 match self {
62 ListItemType::Entry { entry, .. } => Some(entry),
63 ListItemType::SearchResult { entry, .. } => Some(entry),
64 _ => None,
65 }
66 }
67}
68
69pub enum ThreadHistoryEvent {
70 Open(AgentSessionInfo),
71}
72
73impl EventEmitter<ThreadHistoryEvent> for ThreadHistory {}
74
75impl ThreadHistory {
76 pub fn new(
77 session_list: Option<Rc<dyn AgentSessionList>>,
78 window: &mut Window,
79 cx: &mut Context<Self>,
80 ) -> Self {
81 let search_editor = cx.new(|cx| {
82 let mut editor = Editor::single_line(window, cx);
83 editor.set_placeholder_text("Search threads...", window, cx);
84 editor
85 });
86
87 let search_editor_subscription =
88 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
89 if let EditorEvent::BufferEdited = event {
90 let query = search_editor.read(cx).text(cx);
91 if this.search_query != query {
92 this.search_query = query.into();
93 this.update_visible_items(false, cx);
94 }
95 }
96 });
97
98 let scroll_handle = UniformListScrollHandle::default();
99
100 let mut this = Self {
101 session_list: None,
102 sessions: Vec::new(),
103 scroll_handle,
104 selected_index: 0,
105 hovered_index: None,
106 visible_items: Default::default(),
107 search_editor,
108 local_timezone: UtcOffset::from_whole_seconds(
109 chrono::Local::now().offset().local_minus_utc(),
110 )
111 .unwrap(),
112 search_query: SharedString::default(),
113 confirming_delete_history: false,
114 _subscriptions: vec![search_editor_subscription],
115 _visible_items_task: Task::ready(()),
116 _refresh_task: Task::ready(()),
117 _watch_task: None,
118 };
119 this.set_session_list(session_list, cx);
120 this
121 }
122
123 fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
124 let entries = self.sessions.clone();
125 let new_list_items = if self.search_query.is_empty() {
126 self.add_list_separators(entries, cx)
127 } else {
128 self.filter_search_results(entries, cx)
129 };
130 let selected_history_entry = if preserve_selected_item {
131 self.selected_history_entry().cloned()
132 } else {
133 None
134 };
135
136 self._visible_items_task = cx.spawn(async move |this, cx| {
137 let new_visible_items = new_list_items.await;
138 this.update(cx, |this, cx| {
139 let new_selected_index = if let Some(history_entry) = selected_history_entry {
140 new_visible_items
141 .iter()
142 .position(|visible_entry| {
143 visible_entry
144 .history_entry()
145 .is_some_and(|entry| entry.session_id == history_entry.session_id)
146 })
147 .unwrap_or(0)
148 } else {
149 0
150 };
151
152 this.visible_items = new_visible_items;
153 this.set_selected_index(new_selected_index, Bias::Right, cx);
154 cx.notify();
155 })
156 .ok();
157 });
158 }
159
160 pub fn set_session_list(
161 &mut self,
162 session_list: Option<Rc<dyn AgentSessionList>>,
163 cx: &mut Context<Self>,
164 ) {
165 if let (Some(current), Some(next)) = (&self.session_list, &session_list)
166 && Rc::ptr_eq(current, next)
167 {
168 return;
169 }
170
171 self.session_list = session_list;
172 self.sessions.clear();
173 self.visible_items.clear();
174 self.selected_index = 0;
175 self._visible_items_task = Task::ready(());
176 self._refresh_task = Task::ready(());
177
178 let Some(session_list) = self.session_list.as_ref() else {
179 self._watch_task = None;
180 cx.notify();
181 return;
182 };
183 let Some(rx) = session_list.watch(cx) else {
184 // No watch support - do a one-time refresh
185 self._watch_task = None;
186 self.refresh_sessions(false, false, cx);
187 return;
188 };
189 session_list.notify_refresh();
190
191 self._watch_task = Some(cx.spawn(async move |this, cx| {
192 while let Ok(first_update) = rx.recv().await {
193 let mut updates = vec![first_update];
194 // Collect any additional updates that are already in the channel
195 while let Ok(update) = rx.try_recv() {
196 updates.push(update);
197 }
198
199 this.update(cx, |this, cx| {
200 let needs_refresh = updates
201 .iter()
202 .any(|u| matches!(u, SessionListUpdate::Refresh));
203
204 if needs_refresh {
205 this.refresh_sessions(true, false, cx);
206 } else {
207 for update in updates {
208 if let SessionListUpdate::SessionInfo { session_id, update } = update {
209 this.apply_info_update(session_id, update, cx);
210 }
211 }
212 }
213 })
214 .ok();
215 }
216 }));
217 }
218
219 pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
220 self.refresh_sessions(true, true, cx);
221 }
222
223 fn apply_info_update(
224 &mut self,
225 session_id: acp::SessionId,
226 info_update: acp::SessionInfoUpdate,
227 cx: &mut Context<Self>,
228 ) {
229 let Some(session) = self
230 .sessions
231 .iter_mut()
232 .find(|s| s.session_id == session_id)
233 else {
234 return;
235 };
236
237 match info_update.title {
238 acp::MaybeUndefined::Value(title) => {
239 session.title = Some(title.into());
240 }
241 acp::MaybeUndefined::Null => {
242 session.title = None;
243 }
244 acp::MaybeUndefined::Undefined => {}
245 }
246 match info_update.updated_at {
247 acp::MaybeUndefined::Value(date_str) => {
248 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
249 session.updated_at = Some(dt.with_timezone(&chrono::Utc));
250 }
251 }
252 acp::MaybeUndefined::Null => {
253 session.updated_at = None;
254 }
255 acp::MaybeUndefined::Undefined => {}
256 }
257 if let Some(meta) = info_update.meta {
258 session.meta = Some(meta);
259 }
260
261 self.update_visible_items(true, cx);
262 }
263
264 fn refresh_sessions(
265 &mut self,
266 preserve_selected_item: bool,
267 load_all_pages: bool,
268 cx: &mut Context<Self>,
269 ) {
270 let Some(session_list) = self.session_list.clone() else {
271 self.update_visible_items(preserve_selected_item, cx);
272 return;
273 };
274
275 // If a new refresh arrives while pagination is in progress, the previous
276 // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
277 // but means sessions may be in a partial state until the new refresh completes.
278 self._refresh_task = cx.spawn(async move |this, cx| {
279 let mut cursor: Option<String> = None;
280 let mut is_first_page = true;
281
282 loop {
283 let request = AgentSessionListRequest {
284 cursor: cursor.clone(),
285 ..Default::default()
286 };
287 let task = cx.update(|cx| session_list.list_sessions(request, cx));
288 let response = match task.await {
289 Ok(response) => response,
290 Err(error) => {
291 log::error!("Failed to load session history: {error:#}");
292 return;
293 }
294 };
295
296 let acp_thread::AgentSessionListResponse {
297 sessions: page_sessions,
298 next_cursor,
299 ..
300 } = response;
301
302 this.update(cx, |this, cx| {
303 if is_first_page {
304 this.sessions = page_sessions;
305 } else {
306 this.sessions.extend(page_sessions);
307 }
308 this.update_visible_items(preserve_selected_item, cx);
309 })
310 .ok();
311
312 is_first_page = false;
313 if !load_all_pages {
314 break;
315 }
316
317 match next_cursor {
318 Some(next_cursor) => {
319 if cursor.as_ref() == Some(&next_cursor) {
320 log::warn!(
321 "Session list pagination returned the same cursor; stopping to avoid a loop."
322 );
323 break;
324 }
325 cursor = Some(next_cursor);
326 }
327 None => break,
328 }
329 }
330 });
331 }
332
333 pub(crate) fn is_empty(&self) -> bool {
334 self.sessions.is_empty()
335 }
336
337 pub fn has_session_list(&self) -> bool {
338 self.session_list.is_some()
339 }
340
341 pub fn refresh(&mut self, _cx: &mut Context<Self>) {
342 if let Some(session_list) = &self.session_list {
343 session_list.notify_refresh();
344 }
345 }
346
347 pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
348 self.sessions
349 .iter()
350 .find(|entry| &entry.session_id == session_id)
351 .cloned()
352 }
353
354 pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
355 &self.sessions
356 }
357
358 pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
359 self.sessions.iter().take(limit).cloned().collect()
360 }
361
362 pub fn supports_delete(&self) -> bool {
363 self.session_list
364 .as_ref()
365 .map(|sl| sl.supports_delete())
366 .unwrap_or(false)
367 }
368
369 pub(crate) fn delete_session(
370 &self,
371 session_id: &acp::SessionId,
372 cx: &mut App,
373 ) -> Task<anyhow::Result<()>> {
374 if let Some(session_list) = self.session_list.as_ref() {
375 session_list.delete_session(session_id, cx)
376 } else {
377 Task::ready(Ok(()))
378 }
379 }
380
381 fn add_list_separators(
382 &self,
383 entries: Vec<AgentSessionInfo>,
384 cx: &App,
385 ) -> Task<Vec<ListItemType>> {
386 cx.background_spawn(async move {
387 let mut items = Vec::with_capacity(entries.len() + 1);
388 let mut bucket = None;
389 let today = Local::now().naive_local().date();
390
391 for entry in entries.into_iter() {
392 let entry_bucket = entry
393 .updated_at
394 .map(|timestamp| {
395 let entry_date = timestamp.with_timezone(&Local).naive_local().date();
396 TimeBucket::from_dates(today, entry_date)
397 })
398 .unwrap_or(TimeBucket::All);
399
400 if Some(entry_bucket) != bucket {
401 bucket = Some(entry_bucket);
402 items.push(ListItemType::BucketSeparator(entry_bucket));
403 }
404
405 items.push(ListItemType::Entry {
406 entry,
407 format: entry_bucket.into(),
408 });
409 }
410 items
411 })
412 }
413
414 fn filter_search_results(
415 &self,
416 entries: Vec<AgentSessionInfo>,
417 cx: &App,
418 ) -> Task<Vec<ListItemType>> {
419 let query = self.search_query.clone();
420 cx.background_spawn({
421 let executor = cx.background_executor().clone();
422 async move {
423 let mut candidates = Vec::with_capacity(entries.len());
424
425 for (idx, entry) in entries.iter().enumerate() {
426 candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
427 }
428
429 const MAX_MATCHES: usize = 100;
430
431 let matches = fuzzy::match_strings(
432 &candidates,
433 &query,
434 false,
435 true,
436 MAX_MATCHES,
437 &Default::default(),
438 executor,
439 )
440 .await;
441
442 matches
443 .into_iter()
444 .map(|search_match| ListItemType::SearchResult {
445 entry: entries[search_match.candidate_id].clone(),
446 positions: search_match.positions,
447 })
448 .collect()
449 }
450 })
451 }
452
453 fn search_produced_no_matches(&self) -> bool {
454 self.visible_items.is_empty() && !self.search_query.is_empty()
455 }
456
457 fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
458 self.get_history_entry(self.selected_index)
459 }
460
461 fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
462 self.visible_items.get(visible_items_ix)?.history_entry()
463 }
464
465 fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
466 if self.visible_items.len() == 0 {
467 self.selected_index = 0;
468 return;
469 }
470 while matches!(
471 self.visible_items.get(index),
472 None | Some(ListItemType::BucketSeparator(..))
473 ) {
474 index = match bias {
475 Bias::Left => {
476 if index == 0 {
477 self.visible_items.len() - 1
478 } else {
479 index - 1
480 }
481 }
482 Bias::Right => {
483 if index >= self.visible_items.len() - 1 {
484 0
485 } else {
486 index + 1
487 }
488 }
489 };
490 }
491 self.selected_index = index;
492 self.scroll_handle
493 .scroll_to_item(index, ScrollStrategy::Top);
494 cx.notify()
495 }
496
497 pub fn select_previous(
498 &mut self,
499 _: &menu::SelectPrevious,
500 _window: &mut Window,
501 cx: &mut Context<Self>,
502 ) {
503 if self.selected_index == 0 {
504 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
505 } else {
506 self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
507 }
508 }
509
510 pub fn select_next(
511 &mut self,
512 _: &menu::SelectNext,
513 _window: &mut Window,
514 cx: &mut Context<Self>,
515 ) {
516 if self.selected_index == self.visible_items.len() - 1 {
517 self.set_selected_index(0, Bias::Right, cx);
518 } else {
519 self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
520 }
521 }
522
523 fn select_first(
524 &mut self,
525 _: &menu::SelectFirst,
526 _window: &mut Window,
527 cx: &mut Context<Self>,
528 ) {
529 self.set_selected_index(0, Bias::Right, cx);
530 }
531
532 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
533 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
534 }
535
536 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
537 self.confirm_entry(self.selected_index, cx);
538 }
539
540 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
541 let Some(entry) = self.get_history_entry(ix) else {
542 return;
543 };
544 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
545 }
546
547 fn remove_selected_thread(
548 &mut self,
549 _: &RemoveSelectedThread,
550 _window: &mut Window,
551 cx: &mut Context<Self>,
552 ) {
553 self.remove_thread(self.selected_index, cx)
554 }
555
556 fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
557 let Some(entry) = self.get_history_entry(visible_item_ix) else {
558 return;
559 };
560 let Some(session_list) = self.session_list.as_ref() else {
561 return;
562 };
563 if !session_list.supports_delete() {
564 return;
565 }
566 let task = session_list.delete_session(&entry.session_id, cx);
567 task.detach_and_log_err(cx);
568 }
569
570 fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
571 let Some(session_list) = self.session_list.as_ref() else {
572 return;
573 };
574 if !session_list.supports_delete() {
575 return;
576 }
577 session_list.delete_sessions(cx).detach_and_log_err(cx);
578 self.confirming_delete_history = false;
579 cx.notify();
580 }
581
582 fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
583 self.confirming_delete_history = true;
584 cx.notify();
585 }
586
587 fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
588 self.confirming_delete_history = false;
589 cx.notify();
590 }
591
592 fn render_list_items(
593 &mut self,
594 range: Range<usize>,
595 _window: &mut Window,
596 cx: &mut Context<Self>,
597 ) -> Vec<AnyElement> {
598 self.visible_items
599 .get(range.clone())
600 .into_iter()
601 .flatten()
602 .enumerate()
603 .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
604 .collect()
605 }
606
607 fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
608 match item {
609 ListItemType::Entry { entry, format } => self
610 .render_history_entry(entry, *format, ix, Vec::default(), cx)
611 .into_any(),
612 ListItemType::SearchResult { entry, positions } => self.render_history_entry(
613 entry,
614 EntryTimeFormat::DateAndTime,
615 ix,
616 positions.clone(),
617 cx,
618 ),
619 ListItemType::BucketSeparator(bucket) => div()
620 .px(DynamicSpacing::Base06.rems(cx))
621 .pt_2()
622 .pb_1()
623 .child(
624 Label::new(bucket.to_string())
625 .size(LabelSize::XSmall)
626 .color(Color::Muted),
627 )
628 .into_any_element(),
629 }
630 }
631
632 fn render_history_entry(
633 &self,
634 entry: &AgentSessionInfo,
635 format: EntryTimeFormat,
636 ix: usize,
637 highlight_positions: Vec<usize>,
638 cx: &Context<Self>,
639 ) -> AnyElement {
640 let selected = ix == self.selected_index;
641 let hovered = Some(ix) == self.hovered_index;
642 let entry_time = entry.updated_at;
643 let display_text = match (format, entry_time) {
644 (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
645 let now = Utc::now();
646 let duration = now.signed_duration_since(entry_time);
647 let days = duration.num_days();
648
649 format!("{}d", days)
650 }
651 (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
652 format.format_timestamp(entry_time.timestamp(), self.local_timezone)
653 }
654 (_, None) => "—".to_string(),
655 };
656
657 let title = thread_title(entry).clone();
658 let full_date = entry_time
659 .map(|time| {
660 EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
661 })
662 .unwrap_or_else(|| "Unknown".to_string());
663
664 h_flex()
665 .w_full()
666 .pb_1()
667 .child(
668 ListItem::new(ix)
669 .rounded()
670 .toggle_state(selected)
671 .spacing(ListItemSpacing::Sparse)
672 .start_slot(
673 h_flex()
674 .w_full()
675 .gap_2()
676 .justify_between()
677 .child(
678 HighlightedLabel::new(thread_title(entry), highlight_positions)
679 .size(LabelSize::Small)
680 .truncate(),
681 )
682 .child(
683 Label::new(display_text)
684 .color(Color::Muted)
685 .size(LabelSize::XSmall),
686 ),
687 )
688 .tooltip(move |_, cx| {
689 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
690 })
691 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
692 if *is_hovered {
693 this.hovered_index = Some(ix);
694 } else if this.hovered_index == Some(ix) {
695 this.hovered_index = None;
696 }
697
698 cx.notify();
699 }))
700 .end_slot::<IconButton>(if hovered && self.supports_delete() {
701 Some(
702 IconButton::new("delete", IconName::Trash)
703 .shape(IconButtonShape::Square)
704 .icon_size(IconSize::XSmall)
705 .icon_color(Color::Muted)
706 .tooltip(move |_window, cx| {
707 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
708 })
709 .on_click(cx.listener(move |this, _, _, cx| {
710 this.remove_thread(ix, cx);
711 cx.stop_propagation()
712 })),
713 )
714 } else {
715 None
716 })
717 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
718 )
719 .into_any_element()
720 }
721}
722
723impl Focusable for ThreadHistory {
724 fn focus_handle(&self, cx: &App) -> FocusHandle {
725 self.search_editor.focus_handle(cx)
726 }
727}
728
729impl Render for ThreadHistory {
730 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
731 let has_no_history = self.is_empty();
732
733 v_flex()
734 .key_context("ThreadHistory")
735 .size_full()
736 .bg(cx.theme().colors().panel_background)
737 .on_action(cx.listener(Self::select_previous))
738 .on_action(cx.listener(Self::select_next))
739 .on_action(cx.listener(Self::select_first))
740 .on_action(cx.listener(Self::select_last))
741 .on_action(cx.listener(Self::confirm))
742 .on_action(cx.listener(Self::remove_selected_thread))
743 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
744 this.remove_history(window, cx);
745 }))
746 .child(
747 h_flex()
748 .h(Tab::container_height(cx))
749 .w_full()
750 .py_1()
751 .px_2()
752 .gap_2()
753 .justify_between()
754 .border_b_1()
755 .border_color(cx.theme().colors().border)
756 .child(
757 Icon::new(IconName::MagnifyingGlass)
758 .color(Color::Muted)
759 .size(IconSize::Small),
760 )
761 .child(self.search_editor.clone()),
762 )
763 .child({
764 let view = v_flex()
765 .id("list-container")
766 .relative()
767 .overflow_hidden()
768 .flex_grow();
769
770 if has_no_history {
771 view.justify_center().items_center().child(
772 Label::new("You don't have any past threads yet.")
773 .size(LabelSize::Small)
774 .color(Color::Muted),
775 )
776 } else if self.search_produced_no_matches() {
777 view.justify_center()
778 .items_center()
779 .child(Label::new("No threads match your search.").size(LabelSize::Small))
780 } else {
781 view.child(
782 uniform_list(
783 "thread-history",
784 self.visible_items.len(),
785 cx.processor(|this, range: Range<usize>, window, cx| {
786 this.render_list_items(range, window, cx)
787 }),
788 )
789 .p_1()
790 .pr_4()
791 .track_scroll(&self.scroll_handle)
792 .flex_grow(),
793 )
794 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
795 }
796 })
797 .when(!has_no_history && self.supports_delete(), |this| {
798 this.child(
799 h_flex()
800 .p_2()
801 .border_t_1()
802 .border_color(cx.theme().colors().border_variant)
803 .when(!self.confirming_delete_history, |this| {
804 this.child(
805 Button::new("delete_history", "Delete All History")
806 .full_width()
807 .style(ButtonStyle::Outlined)
808 .label_size(LabelSize::Small)
809 .on_click(cx.listener(|this, _, window, cx| {
810 this.prompt_delete_history(window, cx);
811 })),
812 )
813 })
814 .when(self.confirming_delete_history, |this| {
815 this.w_full()
816 .gap_2()
817 .flex_wrap()
818 .justify_between()
819 .child(
820 h_flex()
821 .flex_wrap()
822 .gap_1()
823 .child(
824 Label::new("Delete all threads?")
825 .size(LabelSize::Small),
826 )
827 .child(
828 Label::new("You won't be able to recover them later.")
829 .size(LabelSize::Small)
830 .color(Color::Muted),
831 ),
832 )
833 .child(
834 h_flex()
835 .gap_1()
836 .child(
837 Button::new("cancel_delete", "Cancel")
838 .label_size(LabelSize::Small)
839 .on_click(cx.listener(|this, _, window, cx| {
840 this.cancel_delete_history(window, cx);
841 })),
842 )
843 .child(
844 Button::new("confirm_delete", "Delete")
845 .style(ButtonStyle::Tinted(ui::TintColor::Error))
846 .color(Color::Error)
847 .label_size(LabelSize::Small)
848 .on_click(cx.listener(|_, _, window, cx| {
849 window.dispatch_action(
850 Box::new(RemoveHistory),
851 cx,
852 );
853 })),
854 ),
855 )
856 }),
857 )
858 })
859 }
860}
861
862#[derive(IntoElement)]
863pub struct HistoryEntryElement {
864 entry: AgentSessionInfo,
865 thread_view: WeakEntity<ConnectionView>,
866 selected: bool,
867 hovered: bool,
868 supports_delete: bool,
869 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
870}
871
872impl HistoryEntryElement {
873 pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
874 Self {
875 entry,
876 thread_view,
877 selected: false,
878 hovered: false,
879 supports_delete: false,
880 on_hover: Box::new(|_, _, _| {}),
881 }
882 }
883
884 pub fn supports_delete(mut self, supports_delete: bool) -> Self {
885 self.supports_delete = supports_delete;
886 self
887 }
888
889 pub fn hovered(mut self, hovered: bool) -> Self {
890 self.hovered = hovered;
891 self
892 }
893
894 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
895 self.on_hover = Box::new(on_hover);
896 self
897 }
898}
899
900impl RenderOnce for HistoryEntryElement {
901 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
902 let id = ElementId::Name(self.entry.session_id.0.clone().into());
903 let title = thread_title(&self.entry).clone();
904 let formatted_time = self
905 .entry
906 .updated_at
907 .map(|timestamp| {
908 let now = chrono::Utc::now();
909 let duration = now.signed_duration_since(timestamp);
910
911 if duration.num_days() > 0 {
912 format!("{}d", duration.num_days())
913 } else if duration.num_hours() > 0 {
914 format!("{}h ago", duration.num_hours())
915 } else if duration.num_minutes() > 0 {
916 format!("{}m ago", duration.num_minutes())
917 } else {
918 "Just now".to_string()
919 }
920 })
921 .unwrap_or_else(|| "Unknown".to_string());
922
923 ListItem::new(id)
924 .rounded()
925 .toggle_state(self.selected)
926 .spacing(ListItemSpacing::Sparse)
927 .start_slot(
928 h_flex()
929 .w_full()
930 .gap_2()
931 .justify_between()
932 .child(Label::new(title).size(LabelSize::Small).truncate())
933 .child(
934 Label::new(formatted_time)
935 .color(Color::Muted)
936 .size(LabelSize::XSmall),
937 ),
938 )
939 .on_hover(self.on_hover)
940 .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
941 Some(
942 IconButton::new("delete", IconName::Trash)
943 .shape(IconButtonShape::Square)
944 .icon_size(IconSize::XSmall)
945 .icon_color(Color::Muted)
946 .tooltip(move |_window, cx| {
947 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
948 })
949 .on_click({
950 let thread_view = self.thread_view.clone();
951 let session_id = self.entry.session_id.clone();
952
953 move |_event, _window, cx| {
954 if let Some(thread_view) = thread_view.upgrade() {
955 thread_view.update(cx, |thread_view, cx| {
956 thread_view.delete_history_entry(&session_id, cx);
957 });
958 }
959 }
960 }),
961 )
962 } else {
963 None
964 })
965 .on_click({
966 let thread_view = self.thread_view.clone();
967 let entry = self.entry;
968
969 move |_event, window, cx| {
970 if let Some(workspace) = thread_view
971 .upgrade()
972 .and_then(|view| view.read(cx).workspace().upgrade())
973 {
974 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
975 panel.update(cx, |panel, cx| {
976 panel.load_agent_thread(
977 entry.session_id.clone(),
978 entry.cwd.clone(),
979 entry.title.clone(),
980 window,
981 cx,
982 );
983 });
984 }
985 }
986 }
987 })
988 }
989}
990
991#[derive(Clone, Copy)]
992pub enum EntryTimeFormat {
993 DateAndTime,
994 TimeOnly,
995}
996
997impl EntryTimeFormat {
998 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
999 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
1000
1001 match self {
1002 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
1003 timestamp,
1004 OffsetDateTime::now_utc(),
1005 timezone,
1006 time_format::TimestampFormat::EnhancedAbsolute,
1007 ),
1008 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
1009 }
1010 }
1011}
1012
1013impl From<TimeBucket> for EntryTimeFormat {
1014 fn from(bucket: TimeBucket) -> Self {
1015 match bucket {
1016 TimeBucket::Today => EntryTimeFormat::TimeOnly,
1017 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
1018 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
1019 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
1020 TimeBucket::All => EntryTimeFormat::DateAndTime,
1021 }
1022 }
1023}
1024
1025#[derive(PartialEq, Eq, Clone, Copy, Debug)]
1026enum TimeBucket {
1027 Today,
1028 Yesterday,
1029 ThisWeek,
1030 PastWeek,
1031 All,
1032}
1033
1034impl TimeBucket {
1035 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1036 if date == reference {
1037 return TimeBucket::Today;
1038 }
1039
1040 if date == reference - TimeDelta::days(1) {
1041 return TimeBucket::Yesterday;
1042 }
1043
1044 let week = date.iso_week();
1045
1046 if reference.iso_week() == week {
1047 return TimeBucket::ThisWeek;
1048 }
1049
1050 let last_week = (reference - TimeDelta::days(7)).iso_week();
1051
1052 if week == last_week {
1053 return TimeBucket::PastWeek;
1054 }
1055
1056 TimeBucket::All
1057 }
1058}
1059
1060impl Display for TimeBucket {
1061 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1062 match self {
1063 TimeBucket::Today => write!(f, "Today"),
1064 TimeBucket::Yesterday => write!(f, "Yesterday"),
1065 TimeBucket::ThisWeek => write!(f, "This Week"),
1066 TimeBucket::PastWeek => write!(f, "Past Week"),
1067 TimeBucket::All => write!(f, "All"),
1068 }
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use acp_thread::AgentSessionListResponse;
1076 use chrono::NaiveDate;
1077 use gpui::TestAppContext;
1078 use std::{
1079 any::Any,
1080 sync::{Arc, Mutex},
1081 };
1082
1083 fn init_test(cx: &mut TestAppContext) {
1084 cx.update(|cx| {
1085 let settings_store = settings::SettingsStore::test(cx);
1086 cx.set_global(settings_store);
1087 theme::init(theme::LoadThemes::JustBase, cx);
1088 });
1089 }
1090
1091 #[derive(Clone)]
1092 struct TestSessionList {
1093 sessions: Vec<AgentSessionInfo>,
1094 updates_tx: smol::channel::Sender<SessionListUpdate>,
1095 updates_rx: smol::channel::Receiver<SessionListUpdate>,
1096 }
1097
1098 impl TestSessionList {
1099 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1100 let (tx, rx) = smol::channel::unbounded();
1101 Self {
1102 sessions,
1103 updates_tx: tx,
1104 updates_rx: rx,
1105 }
1106 }
1107
1108 fn send_update(&self, update: SessionListUpdate) {
1109 self.updates_tx.try_send(update).ok();
1110 }
1111 }
1112
1113 impl AgentSessionList for TestSessionList {
1114 fn list_sessions(
1115 &self,
1116 _request: AgentSessionListRequest,
1117 _cx: &mut App,
1118 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1119 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1120 }
1121
1122 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1123 Some(self.updates_rx.clone())
1124 }
1125
1126 fn notify_refresh(&self) {
1127 self.send_update(SessionListUpdate::Refresh);
1128 }
1129
1130 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1131 self
1132 }
1133 }
1134
1135 #[derive(Clone)]
1136 struct PaginatedTestSessionList {
1137 first_page_sessions: Vec<AgentSessionInfo>,
1138 second_page_sessions: Vec<AgentSessionInfo>,
1139 requested_cursors: Arc<Mutex<Vec<Option<String>>>>,
1140 async_responses: bool,
1141 updates_tx: smol::channel::Sender<SessionListUpdate>,
1142 updates_rx: smol::channel::Receiver<SessionListUpdate>,
1143 }
1144
1145 impl PaginatedTestSessionList {
1146 fn new(
1147 first_page_sessions: Vec<AgentSessionInfo>,
1148 second_page_sessions: Vec<AgentSessionInfo>,
1149 ) -> Self {
1150 let (tx, rx) = smol::channel::unbounded();
1151 Self {
1152 first_page_sessions,
1153 second_page_sessions,
1154 requested_cursors: Arc::new(Mutex::new(Vec::new())),
1155 async_responses: false,
1156 updates_tx: tx,
1157 updates_rx: rx,
1158 }
1159 }
1160
1161 fn with_async_responses(mut self) -> Self {
1162 self.async_responses = true;
1163 self
1164 }
1165
1166 fn requested_cursors(&self) -> Vec<Option<String>> {
1167 self.requested_cursors.lock().unwrap().clone()
1168 }
1169
1170 fn clear_requested_cursors(&self) {
1171 self.requested_cursors.lock().unwrap().clear()
1172 }
1173
1174 fn send_update(&self, update: SessionListUpdate) {
1175 self.updates_tx.try_send(update).ok();
1176 }
1177 }
1178
1179 impl AgentSessionList for PaginatedTestSessionList {
1180 fn list_sessions(
1181 &self,
1182 request: AgentSessionListRequest,
1183 cx: &mut App,
1184 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1185 let requested_cursors = self.requested_cursors.clone();
1186 let first_page_sessions = self.first_page_sessions.clone();
1187 let second_page_sessions = self.second_page_sessions.clone();
1188
1189 let respond = move || {
1190 requested_cursors
1191 .lock()
1192 .unwrap()
1193 .push(request.cursor.clone());
1194
1195 match request.cursor.as_deref() {
1196 None => AgentSessionListResponse {
1197 sessions: first_page_sessions,
1198 next_cursor: Some("page-2".to_string()),
1199 meta: None,
1200 },
1201 Some("page-2") => AgentSessionListResponse::new(second_page_sessions),
1202 _ => AgentSessionListResponse::new(Vec::new()),
1203 }
1204 };
1205
1206 if self.async_responses {
1207 cx.foreground_executor().spawn(async move {
1208 smol::future::yield_now().await;
1209 Ok(respond())
1210 })
1211 } else {
1212 Task::ready(Ok(respond()))
1213 }
1214 }
1215
1216 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1217 Some(self.updates_rx.clone())
1218 }
1219
1220 fn notify_refresh(&self) {
1221 self.send_update(SessionListUpdate::Refresh);
1222 }
1223
1224 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1225 self
1226 }
1227 }
1228
1229 fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
1230 AgentSessionInfo {
1231 session_id: acp::SessionId::new(session_id),
1232 cwd: None,
1233 title: Some(title.to_string().into()),
1234 updated_at: None,
1235 created_at: None,
1236 meta: None,
1237 }
1238 }
1239
1240 #[gpui::test]
1241 async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) {
1242 init_test(cx);
1243
1244 let session_list = Rc::new(PaginatedTestSessionList::new(
1245 vec![test_session("session-1", "First")],
1246 vec![test_session("session-2", "Second")],
1247 ));
1248
1249 let (history, cx) = cx.add_window_view(|window, cx| {
1250 ThreadHistory::new(Some(session_list.clone()), window, cx)
1251 });
1252 cx.run_until_parked();
1253
1254 history.update(cx, |history, _cx| {
1255 assert_eq!(history.sessions.len(), 1);
1256 assert_eq!(
1257 history.sessions[0].session_id,
1258 acp::SessionId::new("session-1")
1259 );
1260 });
1261 assert_eq!(session_list.requested_cursors(), vec![None]);
1262 }
1263
1264 #[gpui::test]
1265 async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) {
1266 init_test(cx);
1267
1268 let session_list = Rc::new(PaginatedTestSessionList::new(
1269 vec![test_session("session-1", "First")],
1270 vec![test_session("session-2", "Second")],
1271 ));
1272
1273 let (history, cx) = cx.add_window_view(|window, cx| {
1274 ThreadHistory::new(Some(session_list.clone()), window, cx)
1275 });
1276 cx.run_until_parked();
1277 session_list.clear_requested_cursors();
1278
1279 history.update(cx, |history, cx| history.refresh_full_history(cx));
1280 cx.run_until_parked();
1281
1282 history.update(cx, |history, _cx| {
1283 assert_eq!(history.sessions.len(), 2);
1284 assert_eq!(
1285 history.sessions[0].session_id,
1286 acp::SessionId::new("session-1")
1287 );
1288 assert_eq!(
1289 history.sessions[1].session_id,
1290 acp::SessionId::new("session-2")
1291 );
1292 });
1293 assert_eq!(
1294 session_list.requested_cursors(),
1295 vec![None, Some("page-2".to_string())]
1296 );
1297 }
1298
1299 #[gpui::test]
1300 async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh(
1301 cx: &mut TestAppContext,
1302 ) {
1303 init_test(cx);
1304
1305 let session_list = Rc::new(PaginatedTestSessionList::new(
1306 vec![test_session("session-1", "First")],
1307 vec![test_session("session-2", "Second")],
1308 ));
1309
1310 let (history, cx) = cx.add_window_view(|window, cx| {
1311 ThreadHistory::new(Some(session_list.clone()), window, cx)
1312 });
1313 cx.run_until_parked();
1314
1315 history.update(cx, |history, cx| history.refresh_full_history(cx));
1316 cx.run_until_parked();
1317 session_list.clear_requested_cursors();
1318
1319 history.update(cx, |history, cx| {
1320 history.refresh(cx);
1321 });
1322 cx.run_until_parked();
1323
1324 history.update(cx, |history, _cx| {
1325 assert_eq!(history.sessions.len(), 1);
1326 assert_eq!(
1327 history.sessions[0].session_id,
1328 acp::SessionId::new("session-1")
1329 );
1330 });
1331 assert_eq!(session_list.requested_cursors(), vec![None]);
1332 }
1333
1334 #[gpui::test]
1335 async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) {
1336 init_test(cx);
1337
1338 let session_list = Rc::new(PaginatedTestSessionList::new(
1339 vec![test_session("session-1", "First")],
1340 vec![test_session("session-2", "Second")],
1341 ));
1342
1343 let (history, cx) = cx.add_window_view(|window, cx| {
1344 ThreadHistory::new(Some(session_list.clone()), window, cx)
1345 });
1346 cx.run_until_parked();
1347
1348 history.update(cx, |history, cx| history.refresh_full_history(cx));
1349 cx.run_until_parked();
1350 session_list.clear_requested_cursors();
1351
1352 history.update(cx, |history, cx| history.refresh_full_history(cx));
1353 cx.run_until_parked();
1354
1355 history.update(cx, |history, _cx| {
1356 assert_eq!(history.sessions.len(), 2);
1357 });
1358 assert_eq!(
1359 session_list.requested_cursors(),
1360 vec![None, Some("page-2".to_string())]
1361 );
1362 }
1363
1364 #[gpui::test]
1365 async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) {
1366 init_test(cx);
1367
1368 let second_page_session_id = acp::SessionId::new("session-2");
1369 let session_list = Rc::new(PaginatedTestSessionList::new(
1370 vec![test_session("session-1", "First")],
1371 vec![test_session("session-2", "Second")],
1372 ));
1373
1374 let (history, cx) = cx.add_window_view(|window, cx| {
1375 ThreadHistory::new(Some(session_list.clone()), window, cx)
1376 });
1377 cx.run_until_parked();
1378
1379 history.update(cx, |history, cx| history.refresh_full_history(cx));
1380 cx.run_until_parked();
1381
1382 session_list.clear_requested_cursors();
1383
1384 session_list.send_update(SessionListUpdate::SessionInfo {
1385 session_id: second_page_session_id.clone(),
1386 update: acp::SessionInfoUpdate::new().title("Updated Second"),
1387 });
1388 session_list.send_update(SessionListUpdate::Refresh);
1389 cx.run_until_parked();
1390
1391 history.update(cx, |history, _cx| {
1392 assert_eq!(history.sessions.len(), 1);
1393 assert_eq!(
1394 history.sessions[0].session_id,
1395 acp::SessionId::new("session-1")
1396 );
1397 assert!(
1398 history
1399 .sessions
1400 .iter()
1401 .all(|session| session.session_id != second_page_session_id)
1402 );
1403 });
1404 assert_eq!(session_list.requested_cursors(), vec![None]);
1405 }
1406
1407 #[gpui::test]
1408 async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) {
1409 init_test(cx);
1410
1411 let session_list = Rc::new(
1412 PaginatedTestSessionList::new(
1413 vec![test_session("session-1", "First")],
1414 vec![test_session("session-2", "Second")],
1415 )
1416 .with_async_responses(),
1417 );
1418
1419 let (history, cx) = cx.add_window_view(|window, cx| {
1420 ThreadHistory::new(Some(session_list.clone()), window, cx)
1421 });
1422 cx.run_until_parked();
1423 session_list.clear_requested_cursors();
1424
1425 history.update(cx, |history, cx| history.refresh_full_history(cx));
1426 cx.run_until_parked();
1427
1428 history.update(cx, |history, _cx| {
1429 assert_eq!(history.sessions.len(), 2);
1430 });
1431 assert_eq!(
1432 session_list.requested_cursors(),
1433 vec![None, Some("page-2".to_string())]
1434 );
1435 }
1436
1437 #[gpui::test]
1438 async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1439 init_test(cx);
1440
1441 let session_id = acp::SessionId::new("test-session");
1442 let sessions = vec![AgentSessionInfo {
1443 session_id: session_id.clone(),
1444 cwd: None,
1445 title: Some("Original Title".into()),
1446 updated_at: None,
1447 created_at: None,
1448 meta: None,
1449 }];
1450 let session_list = Rc::new(TestSessionList::new(sessions));
1451
1452 let (history, cx) = cx.add_window_view(|window, cx| {
1453 ThreadHistory::new(Some(session_list.clone()), window, cx)
1454 });
1455 cx.run_until_parked();
1456
1457 // Send a title update
1458 session_list.send_update(SessionListUpdate::SessionInfo {
1459 session_id: session_id.clone(),
1460 update: acp::SessionInfoUpdate::new().title("New Title"),
1461 });
1462 cx.run_until_parked();
1463
1464 // Check that the title was updated
1465 history.update(cx, |history, _cx| {
1466 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1467 assert_eq!(
1468 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1469 Some("New Title")
1470 );
1471 });
1472 }
1473
1474 #[gpui::test]
1475 async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1476 init_test(cx);
1477
1478 let session_id = acp::SessionId::new("test-session");
1479 let sessions = vec![AgentSessionInfo {
1480 session_id: session_id.clone(),
1481 cwd: None,
1482 title: Some("Original Title".into()),
1483 updated_at: None,
1484 created_at: None,
1485 meta: None,
1486 }];
1487 let session_list = Rc::new(TestSessionList::new(sessions));
1488
1489 let (history, cx) = cx.add_window_view(|window, cx| {
1490 ThreadHistory::new(Some(session_list.clone()), window, cx)
1491 });
1492 cx.run_until_parked();
1493
1494 // Send an update that clears the title (null)
1495 session_list.send_update(SessionListUpdate::SessionInfo {
1496 session_id: session_id.clone(),
1497 update: acp::SessionInfoUpdate::new().title(None::<String>),
1498 });
1499 cx.run_until_parked();
1500
1501 // Check that the title was cleared
1502 history.update(cx, |history, _cx| {
1503 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1504 assert_eq!(session.unwrap().title, None);
1505 });
1506 }
1507
1508 #[gpui::test]
1509 async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1510 init_test(cx);
1511
1512 let session_id = acp::SessionId::new("test-session");
1513 let sessions = vec![AgentSessionInfo {
1514 session_id: session_id.clone(),
1515 cwd: None,
1516 title: Some("Original Title".into()),
1517 updated_at: None,
1518 created_at: None,
1519 meta: None,
1520 }];
1521 let session_list = Rc::new(TestSessionList::new(sessions));
1522
1523 let (history, cx) = cx.add_window_view(|window, cx| {
1524 ThreadHistory::new(Some(session_list.clone()), window, cx)
1525 });
1526 cx.run_until_parked();
1527
1528 // Send an update with no fields set (all undefined)
1529 session_list.send_update(SessionListUpdate::SessionInfo {
1530 session_id: session_id.clone(),
1531 update: acp::SessionInfoUpdate::new(),
1532 });
1533 cx.run_until_parked();
1534
1535 // Check that the title is unchanged
1536 history.update(cx, |history, _cx| {
1537 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1538 assert_eq!(
1539 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1540 Some("Original Title")
1541 );
1542 });
1543 }
1544
1545 #[gpui::test]
1546 async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1547 init_test(cx);
1548
1549 let session_id = acp::SessionId::new("test-session");
1550 let sessions = vec![AgentSessionInfo {
1551 session_id: session_id.clone(),
1552 cwd: None,
1553 title: None,
1554 updated_at: None,
1555 created_at: None,
1556 meta: None,
1557 }];
1558 let session_list = Rc::new(TestSessionList::new(sessions));
1559
1560 let (history, cx) = cx.add_window_view(|window, cx| {
1561 ThreadHistory::new(Some(session_list.clone()), window, cx)
1562 });
1563 cx.run_until_parked();
1564
1565 // Send multiple updates before the executor runs
1566 session_list.send_update(SessionListUpdate::SessionInfo {
1567 session_id: session_id.clone(),
1568 update: acp::SessionInfoUpdate::new().title("First Title"),
1569 });
1570 session_list.send_update(SessionListUpdate::SessionInfo {
1571 session_id: session_id.clone(),
1572 update: acp::SessionInfoUpdate::new().title("Second Title"),
1573 });
1574 cx.run_until_parked();
1575
1576 // Check that the final title is "Second Title" (both applied in order)
1577 history.update(cx, |history, _cx| {
1578 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1579 assert_eq!(
1580 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1581 Some("Second Title")
1582 );
1583 });
1584 }
1585
1586 #[gpui::test]
1587 async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1588 init_test(cx);
1589
1590 let session_id = acp::SessionId::new("test-session");
1591 let sessions = vec![AgentSessionInfo {
1592 session_id: session_id.clone(),
1593 cwd: None,
1594 title: Some("Server Title".into()),
1595 updated_at: None,
1596 created_at: None,
1597 meta: None,
1598 }];
1599 let session_list = Rc::new(TestSessionList::new(sessions));
1600
1601 let (history, cx) = cx.add_window_view(|window, cx| {
1602 ThreadHistory::new(Some(session_list.clone()), window, cx)
1603 });
1604 cx.run_until_parked();
1605
1606 // Send an info update followed by a refresh
1607 session_list.send_update(SessionListUpdate::SessionInfo {
1608 session_id: session_id.clone(),
1609 update: acp::SessionInfoUpdate::new().title("Local Update"),
1610 });
1611 session_list.send_update(SessionListUpdate::Refresh);
1612 cx.run_until_parked();
1613
1614 // The refresh should have fetched from server, getting "Server Title"
1615 history.update(cx, |history, _cx| {
1616 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1617 assert_eq!(
1618 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1619 Some("Server Title")
1620 );
1621 });
1622 }
1623
1624 #[gpui::test]
1625 async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1626 init_test(cx);
1627
1628 let session_id = acp::SessionId::new("known-session");
1629 let sessions = vec![AgentSessionInfo {
1630 session_id,
1631 cwd: None,
1632 title: Some("Original".into()),
1633 updated_at: None,
1634 created_at: None,
1635 meta: None,
1636 }];
1637 let session_list = Rc::new(TestSessionList::new(sessions));
1638
1639 let (history, cx) = cx.add_window_view(|window, cx| {
1640 ThreadHistory::new(Some(session_list.clone()), window, cx)
1641 });
1642 cx.run_until_parked();
1643
1644 // Send an update for an unknown session
1645 session_list.send_update(SessionListUpdate::SessionInfo {
1646 session_id: acp::SessionId::new("unknown-session"),
1647 update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1648 });
1649 cx.run_until_parked();
1650
1651 // Check that the known session is unchanged and no crash occurred
1652 history.update(cx, |history, _cx| {
1653 assert_eq!(history.sessions.len(), 1);
1654 assert_eq!(
1655 history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1656 Some("Original")
1657 );
1658 });
1659 }
1660
1661 #[test]
1662 fn test_time_bucket_from_dates() {
1663 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1664
1665 let date = today;
1666 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1667
1668 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1669 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1670
1671 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1672 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1673
1674 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1675 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1676
1677 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1678 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1679
1680 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1681 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1682
1683 // All: not in this week or last week
1684 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1685 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1686
1687 // Test year boundary cases
1688 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1689
1690 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1691 assert_eq!(
1692 TimeBucket::from_dates(new_year, date),
1693 TimeBucket::Yesterday
1694 );
1695
1696 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1697 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1698 }
1699}