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 entry = self.entry.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(entry.clone(), 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(entry.clone(), window, cx);
977 });
978 }
979 }
980 }
981 })
982 }
983}
984
985#[derive(Clone, Copy)]
986pub enum EntryTimeFormat {
987 DateAndTime,
988 TimeOnly,
989}
990
991impl EntryTimeFormat {
992 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
993 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
994
995 match self {
996 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
997 timestamp,
998 OffsetDateTime::now_utc(),
999 timezone,
1000 time_format::TimestampFormat::EnhancedAbsolute,
1001 ),
1002 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
1003 }
1004 }
1005}
1006
1007impl From<TimeBucket> for EntryTimeFormat {
1008 fn from(bucket: TimeBucket) -> Self {
1009 match bucket {
1010 TimeBucket::Today => EntryTimeFormat::TimeOnly,
1011 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
1012 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
1013 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
1014 TimeBucket::All => EntryTimeFormat::DateAndTime,
1015 }
1016 }
1017}
1018
1019#[derive(PartialEq, Eq, Clone, Copy, Debug)]
1020enum TimeBucket {
1021 Today,
1022 Yesterday,
1023 ThisWeek,
1024 PastWeek,
1025 All,
1026}
1027
1028impl TimeBucket {
1029 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
1030 if date == reference {
1031 return TimeBucket::Today;
1032 }
1033
1034 if date == reference - TimeDelta::days(1) {
1035 return TimeBucket::Yesterday;
1036 }
1037
1038 let week = date.iso_week();
1039
1040 if reference.iso_week() == week {
1041 return TimeBucket::ThisWeek;
1042 }
1043
1044 let last_week = (reference - TimeDelta::days(7)).iso_week();
1045
1046 if week == last_week {
1047 return TimeBucket::PastWeek;
1048 }
1049
1050 TimeBucket::All
1051 }
1052}
1053
1054impl Display for TimeBucket {
1055 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1056 match self {
1057 TimeBucket::Today => write!(f, "Today"),
1058 TimeBucket::Yesterday => write!(f, "Yesterday"),
1059 TimeBucket::ThisWeek => write!(f, "This Week"),
1060 TimeBucket::PastWeek => write!(f, "Past Week"),
1061 TimeBucket::All => write!(f, "All"),
1062 }
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use acp_thread::AgentSessionListResponse;
1070 use chrono::NaiveDate;
1071 use gpui::TestAppContext;
1072 use std::{
1073 any::Any,
1074 sync::{Arc, Mutex},
1075 };
1076
1077 fn init_test(cx: &mut TestAppContext) {
1078 cx.update(|cx| {
1079 let settings_store = settings::SettingsStore::test(cx);
1080 cx.set_global(settings_store);
1081 theme::init(theme::LoadThemes::JustBase, cx);
1082 });
1083 }
1084
1085 #[derive(Clone)]
1086 struct TestSessionList {
1087 sessions: Vec<AgentSessionInfo>,
1088 updates_tx: smol::channel::Sender<SessionListUpdate>,
1089 updates_rx: smol::channel::Receiver<SessionListUpdate>,
1090 }
1091
1092 impl TestSessionList {
1093 fn new(sessions: Vec<AgentSessionInfo>) -> Self {
1094 let (tx, rx) = smol::channel::unbounded();
1095 Self {
1096 sessions,
1097 updates_tx: tx,
1098 updates_rx: rx,
1099 }
1100 }
1101
1102 fn send_update(&self, update: SessionListUpdate) {
1103 self.updates_tx.try_send(update).ok();
1104 }
1105 }
1106
1107 impl AgentSessionList for TestSessionList {
1108 fn list_sessions(
1109 &self,
1110 _request: AgentSessionListRequest,
1111 _cx: &mut App,
1112 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1113 Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
1114 }
1115
1116 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1117 Some(self.updates_rx.clone())
1118 }
1119
1120 fn notify_refresh(&self) {
1121 self.send_update(SessionListUpdate::Refresh);
1122 }
1123
1124 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1125 self
1126 }
1127 }
1128
1129 #[derive(Clone)]
1130 struct PaginatedTestSessionList {
1131 first_page_sessions: Vec<AgentSessionInfo>,
1132 second_page_sessions: Vec<AgentSessionInfo>,
1133 requested_cursors: Arc<Mutex<Vec<Option<String>>>>,
1134 async_responses: bool,
1135 updates_tx: smol::channel::Sender<SessionListUpdate>,
1136 updates_rx: smol::channel::Receiver<SessionListUpdate>,
1137 }
1138
1139 impl PaginatedTestSessionList {
1140 fn new(
1141 first_page_sessions: Vec<AgentSessionInfo>,
1142 second_page_sessions: Vec<AgentSessionInfo>,
1143 ) -> Self {
1144 let (tx, rx) = smol::channel::unbounded();
1145 Self {
1146 first_page_sessions,
1147 second_page_sessions,
1148 requested_cursors: Arc::new(Mutex::new(Vec::new())),
1149 async_responses: false,
1150 updates_tx: tx,
1151 updates_rx: rx,
1152 }
1153 }
1154
1155 fn with_async_responses(mut self) -> Self {
1156 self.async_responses = true;
1157 self
1158 }
1159
1160 fn requested_cursors(&self) -> Vec<Option<String>> {
1161 self.requested_cursors.lock().unwrap().clone()
1162 }
1163
1164 fn clear_requested_cursors(&self) {
1165 self.requested_cursors.lock().unwrap().clear()
1166 }
1167
1168 fn send_update(&self, update: SessionListUpdate) {
1169 self.updates_tx.try_send(update).ok();
1170 }
1171 }
1172
1173 impl AgentSessionList for PaginatedTestSessionList {
1174 fn list_sessions(
1175 &self,
1176 request: AgentSessionListRequest,
1177 cx: &mut App,
1178 ) -> Task<anyhow::Result<AgentSessionListResponse>> {
1179 let requested_cursors = self.requested_cursors.clone();
1180 let first_page_sessions = self.first_page_sessions.clone();
1181 let second_page_sessions = self.second_page_sessions.clone();
1182
1183 let respond = move || {
1184 requested_cursors
1185 .lock()
1186 .unwrap()
1187 .push(request.cursor.clone());
1188
1189 match request.cursor.as_deref() {
1190 None => AgentSessionListResponse {
1191 sessions: first_page_sessions,
1192 next_cursor: Some("page-2".to_string()),
1193 meta: None,
1194 },
1195 Some("page-2") => AgentSessionListResponse::new(second_page_sessions),
1196 _ => AgentSessionListResponse::new(Vec::new()),
1197 }
1198 };
1199
1200 if self.async_responses {
1201 cx.foreground_executor().spawn(async move {
1202 smol::future::yield_now().await;
1203 Ok(respond())
1204 })
1205 } else {
1206 Task::ready(Ok(respond()))
1207 }
1208 }
1209
1210 fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
1211 Some(self.updates_rx.clone())
1212 }
1213
1214 fn notify_refresh(&self) {
1215 self.send_update(SessionListUpdate::Refresh);
1216 }
1217
1218 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
1219 self
1220 }
1221 }
1222
1223 fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
1224 AgentSessionInfo {
1225 session_id: acp::SessionId::new(session_id),
1226 cwd: None,
1227 title: Some(title.to_string().into()),
1228 updated_at: None,
1229 meta: None,
1230 }
1231 }
1232
1233 #[gpui::test]
1234 async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) {
1235 init_test(cx);
1236
1237 let session_list = Rc::new(PaginatedTestSessionList::new(
1238 vec![test_session("session-1", "First")],
1239 vec![test_session("session-2", "Second")],
1240 ));
1241
1242 let (history, cx) = cx.add_window_view(|window, cx| {
1243 ThreadHistory::new(Some(session_list.clone()), window, cx)
1244 });
1245 cx.run_until_parked();
1246
1247 history.update(cx, |history, _cx| {
1248 assert_eq!(history.sessions.len(), 1);
1249 assert_eq!(
1250 history.sessions[0].session_id,
1251 acp::SessionId::new("session-1")
1252 );
1253 });
1254 assert_eq!(session_list.requested_cursors(), vec![None]);
1255 }
1256
1257 #[gpui::test]
1258 async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) {
1259 init_test(cx);
1260
1261 let session_list = Rc::new(PaginatedTestSessionList::new(
1262 vec![test_session("session-1", "First")],
1263 vec![test_session("session-2", "Second")],
1264 ));
1265
1266 let (history, cx) = cx.add_window_view(|window, cx| {
1267 ThreadHistory::new(Some(session_list.clone()), window, cx)
1268 });
1269 cx.run_until_parked();
1270 session_list.clear_requested_cursors();
1271
1272 history.update(cx, |history, cx| history.refresh_full_history(cx));
1273 cx.run_until_parked();
1274
1275 history.update(cx, |history, _cx| {
1276 assert_eq!(history.sessions.len(), 2);
1277 assert_eq!(
1278 history.sessions[0].session_id,
1279 acp::SessionId::new("session-1")
1280 );
1281 assert_eq!(
1282 history.sessions[1].session_id,
1283 acp::SessionId::new("session-2")
1284 );
1285 });
1286 assert_eq!(
1287 session_list.requested_cursors(),
1288 vec![None, Some("page-2".to_string())]
1289 );
1290 }
1291
1292 #[gpui::test]
1293 async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh(
1294 cx: &mut TestAppContext,
1295 ) {
1296 init_test(cx);
1297
1298 let session_list = Rc::new(PaginatedTestSessionList::new(
1299 vec![test_session("session-1", "First")],
1300 vec![test_session("session-2", "Second")],
1301 ));
1302
1303 let (history, cx) = cx.add_window_view(|window, cx| {
1304 ThreadHistory::new(Some(session_list.clone()), window, cx)
1305 });
1306 cx.run_until_parked();
1307
1308 history.update(cx, |history, cx| history.refresh_full_history(cx));
1309 cx.run_until_parked();
1310 session_list.clear_requested_cursors();
1311
1312 history.update(cx, |history, cx| {
1313 history.refresh(cx);
1314 });
1315 cx.run_until_parked();
1316
1317 history.update(cx, |history, _cx| {
1318 assert_eq!(history.sessions.len(), 1);
1319 assert_eq!(
1320 history.sessions[0].session_id,
1321 acp::SessionId::new("session-1")
1322 );
1323 });
1324 assert_eq!(session_list.requested_cursors(), vec![None]);
1325 }
1326
1327 #[gpui::test]
1328 async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) {
1329 init_test(cx);
1330
1331 let session_list = Rc::new(PaginatedTestSessionList::new(
1332 vec![test_session("session-1", "First")],
1333 vec![test_session("session-2", "Second")],
1334 ));
1335
1336 let (history, cx) = cx.add_window_view(|window, cx| {
1337 ThreadHistory::new(Some(session_list.clone()), window, cx)
1338 });
1339 cx.run_until_parked();
1340
1341 history.update(cx, |history, cx| history.refresh_full_history(cx));
1342 cx.run_until_parked();
1343 session_list.clear_requested_cursors();
1344
1345 history.update(cx, |history, cx| history.refresh_full_history(cx));
1346 cx.run_until_parked();
1347
1348 history.update(cx, |history, _cx| {
1349 assert_eq!(history.sessions.len(), 2);
1350 });
1351 assert_eq!(
1352 session_list.requested_cursors(),
1353 vec![None, Some("page-2".to_string())]
1354 );
1355 }
1356
1357 #[gpui::test]
1358 async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) {
1359 init_test(cx);
1360
1361 let second_page_session_id = acp::SessionId::new("session-2");
1362 let session_list = Rc::new(PaginatedTestSessionList::new(
1363 vec![test_session("session-1", "First")],
1364 vec![test_session("session-2", "Second")],
1365 ));
1366
1367 let (history, cx) = cx.add_window_view(|window, cx| {
1368 ThreadHistory::new(Some(session_list.clone()), window, cx)
1369 });
1370 cx.run_until_parked();
1371
1372 history.update(cx, |history, cx| history.refresh_full_history(cx));
1373 cx.run_until_parked();
1374
1375 session_list.clear_requested_cursors();
1376
1377 session_list.send_update(SessionListUpdate::SessionInfo {
1378 session_id: second_page_session_id.clone(),
1379 update: acp::SessionInfoUpdate::new().title("Updated Second"),
1380 });
1381 session_list.send_update(SessionListUpdate::Refresh);
1382 cx.run_until_parked();
1383
1384 history.update(cx, |history, _cx| {
1385 assert_eq!(history.sessions.len(), 1);
1386 assert_eq!(
1387 history.sessions[0].session_id,
1388 acp::SessionId::new("session-1")
1389 );
1390 assert!(
1391 history
1392 .sessions
1393 .iter()
1394 .all(|session| session.session_id != second_page_session_id)
1395 );
1396 });
1397 assert_eq!(session_list.requested_cursors(), vec![None]);
1398 }
1399
1400 #[gpui::test]
1401 async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) {
1402 init_test(cx);
1403
1404 let session_list = Rc::new(
1405 PaginatedTestSessionList::new(
1406 vec![test_session("session-1", "First")],
1407 vec![test_session("session-2", "Second")],
1408 )
1409 .with_async_responses(),
1410 );
1411
1412 let (history, cx) = cx.add_window_view(|window, cx| {
1413 ThreadHistory::new(Some(session_list.clone()), window, cx)
1414 });
1415 cx.run_until_parked();
1416 session_list.clear_requested_cursors();
1417
1418 history.update(cx, |history, cx| history.refresh_full_history(cx));
1419 cx.run_until_parked();
1420
1421 history.update(cx, |history, _cx| {
1422 assert_eq!(history.sessions.len(), 2);
1423 });
1424 assert_eq!(
1425 session_list.requested_cursors(),
1426 vec![None, Some("page-2".to_string())]
1427 );
1428 }
1429
1430 #[gpui::test]
1431 async fn test_apply_info_update_title(cx: &mut TestAppContext) {
1432 init_test(cx);
1433
1434 let session_id = acp::SessionId::new("test-session");
1435 let sessions = vec![AgentSessionInfo {
1436 session_id: session_id.clone(),
1437 cwd: None,
1438 title: Some("Original Title".into()),
1439 updated_at: None,
1440 meta: None,
1441 }];
1442 let session_list = Rc::new(TestSessionList::new(sessions));
1443
1444 let (history, cx) = cx.add_window_view(|window, cx| {
1445 ThreadHistory::new(Some(session_list.clone()), window, cx)
1446 });
1447 cx.run_until_parked();
1448
1449 // Send a title update
1450 session_list.send_update(SessionListUpdate::SessionInfo {
1451 session_id: session_id.clone(),
1452 update: acp::SessionInfoUpdate::new().title("New Title"),
1453 });
1454 cx.run_until_parked();
1455
1456 // Check that the title was updated
1457 history.update(cx, |history, _cx| {
1458 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1459 assert_eq!(
1460 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1461 Some("New Title")
1462 );
1463 });
1464 }
1465
1466 #[gpui::test]
1467 async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
1468 init_test(cx);
1469
1470 let session_id = acp::SessionId::new("test-session");
1471 let sessions = vec![AgentSessionInfo {
1472 session_id: session_id.clone(),
1473 cwd: None,
1474 title: Some("Original Title".into()),
1475 updated_at: None,
1476 meta: None,
1477 }];
1478 let session_list = Rc::new(TestSessionList::new(sessions));
1479
1480 let (history, cx) = cx.add_window_view(|window, cx| {
1481 ThreadHistory::new(Some(session_list.clone()), window, cx)
1482 });
1483 cx.run_until_parked();
1484
1485 // Send an update that clears the title (null)
1486 session_list.send_update(SessionListUpdate::SessionInfo {
1487 session_id: session_id.clone(),
1488 update: acp::SessionInfoUpdate::new().title(None::<String>),
1489 });
1490 cx.run_until_parked();
1491
1492 // Check that the title was cleared
1493 history.update(cx, |history, _cx| {
1494 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1495 assert_eq!(session.unwrap().title, None);
1496 });
1497 }
1498
1499 #[gpui::test]
1500 async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
1501 init_test(cx);
1502
1503 let session_id = acp::SessionId::new("test-session");
1504 let sessions = vec![AgentSessionInfo {
1505 session_id: session_id.clone(),
1506 cwd: None,
1507 title: Some("Original Title".into()),
1508 updated_at: None,
1509 meta: None,
1510 }];
1511 let session_list = Rc::new(TestSessionList::new(sessions));
1512
1513 let (history, cx) = cx.add_window_view(|window, cx| {
1514 ThreadHistory::new(Some(session_list.clone()), window, cx)
1515 });
1516 cx.run_until_parked();
1517
1518 // Send an update with no fields set (all undefined)
1519 session_list.send_update(SessionListUpdate::SessionInfo {
1520 session_id: session_id.clone(),
1521 update: acp::SessionInfoUpdate::new(),
1522 });
1523 cx.run_until_parked();
1524
1525 // Check that the title is unchanged
1526 history.update(cx, |history, _cx| {
1527 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1528 assert_eq!(
1529 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1530 Some("Original Title")
1531 );
1532 });
1533 }
1534
1535 #[gpui::test]
1536 async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
1537 init_test(cx);
1538
1539 let session_id = acp::SessionId::new("test-session");
1540 let sessions = vec![AgentSessionInfo {
1541 session_id: session_id.clone(),
1542 cwd: None,
1543 title: None,
1544 updated_at: None,
1545 meta: None,
1546 }];
1547 let session_list = Rc::new(TestSessionList::new(sessions));
1548
1549 let (history, cx) = cx.add_window_view(|window, cx| {
1550 ThreadHistory::new(Some(session_list.clone()), window, cx)
1551 });
1552 cx.run_until_parked();
1553
1554 // Send multiple updates before the executor runs
1555 session_list.send_update(SessionListUpdate::SessionInfo {
1556 session_id: session_id.clone(),
1557 update: acp::SessionInfoUpdate::new().title("First Title"),
1558 });
1559 session_list.send_update(SessionListUpdate::SessionInfo {
1560 session_id: session_id.clone(),
1561 update: acp::SessionInfoUpdate::new().title("Second Title"),
1562 });
1563 cx.run_until_parked();
1564
1565 // Check that the final title is "Second Title" (both applied in order)
1566 history.update(cx, |history, _cx| {
1567 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1568 assert_eq!(
1569 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1570 Some("Second Title")
1571 );
1572 });
1573 }
1574
1575 #[gpui::test]
1576 async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
1577 init_test(cx);
1578
1579 let session_id = acp::SessionId::new("test-session");
1580 let sessions = vec![AgentSessionInfo {
1581 session_id: session_id.clone(),
1582 cwd: None,
1583 title: Some("Server Title".into()),
1584 updated_at: None,
1585 meta: None,
1586 }];
1587 let session_list = Rc::new(TestSessionList::new(sessions));
1588
1589 let (history, cx) = cx.add_window_view(|window, cx| {
1590 ThreadHistory::new(Some(session_list.clone()), window, cx)
1591 });
1592 cx.run_until_parked();
1593
1594 // Send an info update followed by a refresh
1595 session_list.send_update(SessionListUpdate::SessionInfo {
1596 session_id: session_id.clone(),
1597 update: acp::SessionInfoUpdate::new().title("Local Update"),
1598 });
1599 session_list.send_update(SessionListUpdate::Refresh);
1600 cx.run_until_parked();
1601
1602 // The refresh should have fetched from server, getting "Server Title"
1603 history.update(cx, |history, _cx| {
1604 let session = history.sessions.iter().find(|s| s.session_id == session_id);
1605 assert_eq!(
1606 session.unwrap().title.as_ref().map(|s| s.as_ref()),
1607 Some("Server Title")
1608 );
1609 });
1610 }
1611
1612 #[gpui::test]
1613 async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
1614 init_test(cx);
1615
1616 let session_id = acp::SessionId::new("known-session");
1617 let sessions = vec![AgentSessionInfo {
1618 session_id,
1619 cwd: None,
1620 title: Some("Original".into()),
1621 updated_at: None,
1622 meta: None,
1623 }];
1624 let session_list = Rc::new(TestSessionList::new(sessions));
1625
1626 let (history, cx) = cx.add_window_view(|window, cx| {
1627 ThreadHistory::new(Some(session_list.clone()), window, cx)
1628 });
1629 cx.run_until_parked();
1630
1631 // Send an update for an unknown session
1632 session_list.send_update(SessionListUpdate::SessionInfo {
1633 session_id: acp::SessionId::new("unknown-session"),
1634 update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
1635 });
1636 cx.run_until_parked();
1637
1638 // Check that the known session is unchanged and no crash occurred
1639 history.update(cx, |history, _cx| {
1640 assert_eq!(history.sessions.len(), 1);
1641 assert_eq!(
1642 history.sessions[0].title.as_ref().map(|s| s.as_ref()),
1643 Some("Original")
1644 );
1645 });
1646 }
1647
1648 #[test]
1649 fn test_time_bucket_from_dates() {
1650 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
1651
1652 let date = today;
1653 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
1654
1655 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
1656 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
1657
1658 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
1659 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1660
1661 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
1662 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
1663
1664 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
1665 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1666
1667 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
1668 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
1669
1670 // All: not in this week or last week
1671 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1672 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
1673
1674 // Test year boundary cases
1675 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1676
1677 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
1678 assert_eq!(
1679 TimeBucket::from_dates(new_year, date),
1680 TimeBucket::Yesterday
1681 );
1682
1683 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
1684 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
1685 }
1686}