1use std::sync::Arc;
2
3use crate::{
4 Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore,
5 thread_history::ThreadHistory,
6};
7use acp_thread::AgentSessionInfo;
8use agent::ThreadStore;
9use agent_client_protocol as acp;
10use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
11use editor::Editor;
12use fs::Fs;
13use gpui::{
14 AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
15 SharedString, Subscription, Task, Window, list, prelude::*, px,
16};
17use itertools::Itertools as _;
18use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
19use project::{AgentId, AgentServerStore};
20use theme::ActiveTheme;
21use ui::{
22 ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, KeyBinding,
23 ListItem, PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
24 utils::platform_title_bar_height,
25};
26use util::ResultExt as _;
27use zed_actions::editor::{MoveDown, MoveUp};
28
29#[derive(Clone)]
30enum ArchiveListItem {
31 BucketSeparator(TimeBucket),
32 Entry {
33 session: AgentSessionInfo,
34 highlight_positions: Vec<usize>,
35 },
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum TimeBucket {
40 Today,
41 Yesterday,
42 ThisWeek,
43 PastWeek,
44 Older,
45}
46
47impl TimeBucket {
48 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
49 if date == reference {
50 return TimeBucket::Today;
51 }
52 if date == reference - TimeDelta::days(1) {
53 return TimeBucket::Yesterday;
54 }
55 let week = date.iso_week();
56 if reference.iso_week() == week {
57 return TimeBucket::ThisWeek;
58 }
59 let last_week = (reference - TimeDelta::days(7)).iso_week();
60 if week == last_week {
61 return TimeBucket::PastWeek;
62 }
63 TimeBucket::Older
64 }
65
66 fn label(&self) -> &'static str {
67 match self {
68 TimeBucket::Today => "Today",
69 TimeBucket::Yesterday => "Yesterday",
70 TimeBucket::ThisWeek => "This Week",
71 TimeBucket::PastWeek => "Past Week",
72 TimeBucket::Older => "Older",
73 }
74 }
75}
76
77fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
78 let query = query.to_lowercase();
79 let text_lower = text.to_lowercase();
80 let mut positions = Vec::new();
81 let mut query_chars = query.chars().peekable();
82 for (i, c) in text_lower.chars().enumerate() {
83 if query_chars.peek() == Some(&c) {
84 positions.push(i);
85 query_chars.next();
86 }
87 }
88 if query_chars.peek().is_none() {
89 Some(positions)
90 } else {
91 None
92 }
93}
94
95fn archive_empty_state_message(
96 has_history: bool,
97 is_empty: bool,
98 has_query: bool,
99) -> Option<&'static str> {
100 if !is_empty {
101 None
102 } else if !has_history {
103 Some("This agent does not support viewing archived threads.")
104 } else if has_query {
105 Some("No threads match your search.")
106 } else {
107 Some("No archived threads yet.")
108 }
109}
110
111pub enum ThreadsArchiveViewEvent {
112 Close,
113 Unarchive {
114 agent: Agent,
115 session_info: AgentSessionInfo,
116 },
117}
118
119impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
120
121pub struct ThreadsArchiveView {
122 agent_connection_store: Entity<AgentConnectionStore>,
123 agent_server_store: Entity<AgentServerStore>,
124 thread_store: Entity<ThreadStore>,
125 fs: Arc<dyn Fs>,
126 history: Option<Entity<ThreadHistory>>,
127 _history_subscription: Subscription,
128 selected_agent: Agent,
129 focus_handle: FocusHandle,
130 list_state: ListState,
131 items: Vec<ArchiveListItem>,
132 selection: Option<usize>,
133 hovered_index: Option<usize>,
134 filter_editor: Entity<Editor>,
135 _subscriptions: Vec<gpui::Subscription>,
136 selected_agent_menu: PopoverMenuHandle<ContextMenu>,
137 _refresh_history_task: Task<()>,
138 is_loading: bool,
139}
140
141impl ThreadsArchiveView {
142 pub fn new(
143 agent_connection_store: Entity<AgentConnectionStore>,
144 agent_server_store: Entity<AgentServerStore>,
145 thread_store: Entity<ThreadStore>,
146 fs: Arc<dyn Fs>,
147 window: &mut Window,
148 cx: &mut Context<Self>,
149 ) -> Self {
150 let focus_handle = cx.focus_handle();
151
152 let filter_editor = cx.new(|cx| {
153 let mut editor = Editor::single_line(window, cx);
154 editor.set_placeholder_text("Search archive…", window, cx);
155 editor
156 });
157
158 let filter_editor_subscription =
159 cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
160 if let editor::EditorEvent::BufferEdited = event {
161 this.update_items(cx);
162 }
163 });
164
165 let mut this = Self {
166 agent_connection_store,
167 agent_server_store,
168 thread_store,
169 fs,
170 history: None,
171 _history_subscription: Subscription::new(|| {}),
172 selected_agent: Agent::NativeAgent,
173 focus_handle,
174 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
175 items: Vec::new(),
176 selection: None,
177 hovered_index: None,
178 filter_editor,
179 _subscriptions: vec![filter_editor_subscription],
180 selected_agent_menu: PopoverMenuHandle::default(),
181 _refresh_history_task: Task::ready(()),
182 is_loading: true,
183 };
184 this.set_selected_agent(Agent::NativeAgent, window, cx);
185 this
186 }
187
188 fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
189 self.selected_agent = agent.clone();
190 self.is_loading = true;
191 self.reset_history_subscription();
192 self.history = None;
193 self.items.clear();
194 self.selection = None;
195 self.list_state.reset(0);
196 self.reset_filter_editor_text(window, cx);
197
198 let server = agent.server(self.fs.clone(), self.thread_store.clone());
199 let connection = self
200 .agent_connection_store
201 .update(cx, |store, cx| store.request_connection(agent, server, cx));
202
203 let task = connection.read(cx).wait_for_connection();
204 self._refresh_history_task = cx.spawn(async move |this, cx| {
205 if let Some(state) = task.await.log_err() {
206 this.update(cx, |this, cx| this.set_history(state.history, cx))
207 .ok();
208 }
209 });
210
211 cx.notify();
212 }
213
214 fn reset_history_subscription(&mut self) {
215 self._history_subscription = Subscription::new(|| {});
216 }
217
218 fn set_history(&mut self, history: Option<Entity<ThreadHistory>>, cx: &mut Context<Self>) {
219 self.reset_history_subscription();
220
221 if let Some(history) = &history {
222 self._history_subscription = cx.observe(history, |this, _, cx| {
223 this.update_items(cx);
224 });
225 history.update(cx, |history, cx| {
226 history.refresh_full_history(cx);
227 });
228 }
229 self.history = history;
230 self.is_loading = false;
231 self.update_items(cx);
232 cx.notify();
233 }
234
235 fn update_items(&mut self, cx: &mut Context<Self>) {
236 let sessions = self
237 .history
238 .as_ref()
239 .map(|h| h.read(cx).sessions().to_vec())
240 .unwrap_or_default();
241 let query = self.filter_editor.read(cx).text(cx).to_lowercase();
242 let today = Local::now().naive_local().date();
243
244 let mut items = Vec::with_capacity(sessions.len() + 5);
245 let mut current_bucket: Option<TimeBucket> = None;
246
247 for session in sessions {
248 let highlight_positions = if !query.is_empty() {
249 let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
250 match fuzzy_match_positions(&query, title) {
251 Some(positions) => positions,
252 None => continue,
253 }
254 } else {
255 Vec::new()
256 };
257
258 let entry_bucket = session
259 .updated_at
260 .map(|timestamp| {
261 let entry_date = timestamp.with_timezone(&Local).naive_local().date();
262 TimeBucket::from_dates(today, entry_date)
263 })
264 .unwrap_or(TimeBucket::Older);
265
266 if Some(entry_bucket) != current_bucket {
267 current_bucket = Some(entry_bucket);
268 items.push(ArchiveListItem::BucketSeparator(entry_bucket));
269 }
270
271 items.push(ArchiveListItem::Entry {
272 session,
273 highlight_positions,
274 });
275 }
276
277 self.list_state.reset(items.len());
278 self.items = items;
279 self.selection = None;
280 self.hovered_index = None;
281 cx.notify();
282 }
283
284 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
285 self.filter_editor.update(cx, |editor, cx| {
286 editor.set_text("", window, cx);
287 });
288 }
289
290 fn go_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
291 self.reset_filter_editor_text(window, cx);
292 cx.emit(ThreadsArchiveViewEvent::Close);
293 }
294
295 fn unarchive_thread(
296 &mut self,
297 session_info: AgentSessionInfo,
298 window: &mut Window,
299 cx: &mut Context<Self>,
300 ) {
301 self.selection = None;
302 self.reset_filter_editor_text(window, cx);
303 cx.emit(ThreadsArchiveViewEvent::Unarchive {
304 agent: self.selected_agent.clone(),
305 session_info,
306 });
307 }
308
309 fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
310 let Some(history) = &self.history else {
311 return;
312 };
313 if !history.read(cx).supports_delete() {
314 return;
315 }
316 let session_id = session_id.clone();
317 history.update(cx, |history, cx| {
318 history
319 .delete_session(&session_id, cx)
320 .detach_and_log_err(cx);
321 });
322 }
323
324 fn remove_selected_thread(
325 &mut self,
326 _: &RemoveSelectedThread,
327 _window: &mut Window,
328 cx: &mut Context<Self>,
329 ) {
330 let Some(ix) = self.selection else {
331 return;
332 };
333 let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
334 return;
335 };
336 let session_id = session.session_id.clone();
337 self.delete_thread(&session_id, cx);
338 }
339
340 fn is_selectable_item(&self, ix: usize) -> bool {
341 matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
342 }
343
344 fn find_next_selectable(&self, start: usize) -> Option<usize> {
345 (start..self.items.len()).find(|&i| self.is_selectable_item(i))
346 }
347
348 fn find_previous_selectable(&self, start: usize) -> Option<usize> {
349 (0..=start).rev().find(|&i| self.is_selectable_item(i))
350 }
351
352 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
353 self.select_next(&SelectNext, window, cx);
354 }
355
356 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
357 self.select_previous(&SelectPrevious, window, cx);
358 }
359
360 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
361 let next = match self.selection {
362 Some(ix) => self.find_next_selectable(ix + 1),
363 None => self.find_next_selectable(0),
364 };
365 if let Some(next) = next {
366 self.selection = Some(next);
367 self.list_state.scroll_to_reveal_item(next);
368 cx.notify();
369 }
370 }
371
372 fn select_previous(
373 &mut self,
374 _: &SelectPrevious,
375 _window: &mut Window,
376 cx: &mut Context<Self>,
377 ) {
378 let prev = match self.selection {
379 Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
380 None => {
381 let last = self.items.len().saturating_sub(1);
382 self.find_previous_selectable(last)
383 }
384 _ => return,
385 };
386 if let Some(prev) = prev {
387 self.selection = Some(prev);
388 self.list_state.scroll_to_reveal_item(prev);
389 cx.notify();
390 }
391 }
392
393 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
394 if let Some(first) = self.find_next_selectable(0) {
395 self.selection = Some(first);
396 self.list_state.scroll_to_reveal_item(first);
397 cx.notify();
398 }
399 }
400
401 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
402 let last = self.items.len().saturating_sub(1);
403 if let Some(last) = self.find_previous_selectable(last) {
404 self.selection = Some(last);
405 self.list_state.scroll_to_reveal_item(last);
406 cx.notify();
407 }
408 }
409
410 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
411 let Some(ix) = self.selection else { return };
412 let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
413 return;
414 };
415
416 let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
417 if !can_unarchive {
418 return;
419 }
420
421 self.unarchive_thread(session.clone(), window, cx);
422 }
423
424 fn render_list_entry(
425 &mut self,
426 ix: usize,
427 _window: &mut Window,
428 cx: &mut Context<Self>,
429 ) -> AnyElement {
430 let Some(item) = self.items.get(ix) else {
431 return div().into_any_element();
432 };
433
434 match item {
435 ArchiveListItem::BucketSeparator(bucket) => div()
436 .w_full()
437 .px_2p5()
438 .pt_3()
439 .pb_1()
440 .child(
441 Label::new(bucket.label())
442 .size(LabelSize::Small)
443 .color(Color::Muted),
444 )
445 .into_any_element(),
446 ArchiveListItem::Entry {
447 session,
448 highlight_positions,
449 } => {
450 let id = SharedString::from(format!("archive-entry-{}", ix));
451
452 let is_focused = self.selection == Some(ix);
453 let hovered = self.hovered_index == Some(ix);
454
455 let project_names = session.work_dirs.as_ref().and_then(|paths| {
456 let paths_str = paths
457 .paths()
458 .iter()
459 .filter_map(|p| p.file_name())
460 .filter_map(|name| name.to_str())
461 .join(", ");
462 if paths_str.is_empty() {
463 None
464 } else {
465 Some(paths_str)
466 }
467 });
468
469 let can_unarchive = session.work_dirs.as_ref().is_some_and(|p| !p.is_empty());
470
471 let supports_delete = self
472 .history
473 .as_ref()
474 .map(|h| h.read(cx).supports_delete())
475 .unwrap_or(false);
476
477 let title: SharedString =
478 session.title.clone().unwrap_or_else(|| "Untitled".into());
479
480 let session_info = session.clone();
481 let session_id_for_delete = session.session_id.clone();
482 let focus_handle = self.focus_handle.clone();
483
484 let timestamp = session
485 .created_at
486 .or(session.updated_at)
487 .map(format_history_entry_timestamp);
488
489 let highlight_positions = highlight_positions.clone();
490 let title_label = if highlight_positions.is_empty() {
491 Label::new(title).truncate().into_any_element()
492 } else {
493 HighlightedLabel::new(title, highlight_positions)
494 .truncate()
495 .into_any_element()
496 };
497
498 ListItem::new(id)
499 .focused(is_focused)
500 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
501 if *is_hovered {
502 this.hovered_index = Some(ix);
503 } else if this.hovered_index == Some(ix) {
504 this.hovered_index = None;
505 }
506 cx.notify();
507 }))
508 .child(
509 v_flex()
510 .min_w_0()
511 .w_full()
512 .py_1()
513 .pl_1()
514 .child(title_label)
515 .child(
516 h_flex()
517 .gap_1()
518 .when_some(timestamp, |this, ts| {
519 this.child(
520 Label::new(ts)
521 .size(LabelSize::Small)
522 .color(Color::Muted),
523 )
524 })
525 .when_some(project_names, |this, project| {
526 this.child(
527 Label::new("•")
528 .size(LabelSize::Small)
529 .color(Color::Muted)
530 .alpha(0.5),
531 )
532 .child(
533 Label::new(project)
534 .size(LabelSize::Small)
535 .color(Color::Muted),
536 )
537 }),
538 ),
539 )
540 .when(hovered || is_focused, |this| {
541 this.end_slot(
542 h_flex()
543 .pr_2p5()
544 .gap_0p5()
545 .when(can_unarchive, |this| {
546 this.child(
547 Button::new("unarchive-thread", "Unarchive")
548 .style(ButtonStyle::OutlinedGhost)
549 .label_size(LabelSize::Small)
550 .when(is_focused, |this| {
551 this.key_binding(
552 KeyBinding::for_action_in(
553 &menu::Confirm,
554 &focus_handle,
555 cx,
556 )
557 .map(|kb| kb.size(rems_from_px(12.))),
558 )
559 })
560 .on_click(cx.listener(move |this, _, window, cx| {
561 this.unarchive_thread(
562 session_info.clone(),
563 window,
564 cx,
565 );
566 })),
567 )
568 })
569 .when(supports_delete, |this| {
570 this.child(
571 IconButton::new("delete-thread", IconName::Trash)
572 .icon_size(IconSize::Small)
573 .icon_color(Color::Muted)
574 .tooltip({
575 move |_window, cx| {
576 Tooltip::for_action_in(
577 "Delete Thread",
578 &RemoveSelectedThread,
579 &focus_handle,
580 cx,
581 )
582 }
583 })
584 .on_click(cx.listener(move |this, _, _, cx| {
585 this.delete_thread(&session_id_for_delete, cx);
586 cx.stop_propagation();
587 })),
588 )
589 }),
590 )
591 })
592 .into_any_element()
593 }
594 }
595 }
596
597 fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
598 let agent_server_store = self.agent_server_store.clone();
599
600 let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
601 (IconName::ChevronUp, Color::Accent)
602 } else {
603 (IconName::ChevronDown, Color::Muted)
604 };
605
606 let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
607 let store = agent_server_store.read(cx);
608 let icon = store.agent_icon(&id);
609
610 if let Some(icon) = icon {
611 Icon::from_external_svg(icon)
612 } else {
613 Icon::new(IconName::Sparkle)
614 }
615 .color(Color::Muted)
616 .size(IconSize::Small)
617 } else {
618 Icon::new(IconName::ZedAgent)
619 .color(Color::Muted)
620 .size(IconSize::Small)
621 };
622
623 let this = cx.weak_entity();
624
625 PopoverMenu::new("agent_history_menu")
626 .trigger(
627 ButtonLike::new("selected_agent")
628 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
629 .child(
630 h_flex().gap_1().child(selected_agent_icon).child(
631 Icon::new(chevron_icon)
632 .color(icon_color)
633 .size(IconSize::XSmall),
634 ),
635 ),
636 )
637 .menu(move |window, cx| {
638 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
639 menu.item(
640 ContextMenuEntry::new("Zed Agent")
641 .icon(IconName::ZedAgent)
642 .icon_color(Color::Muted)
643 .handler({
644 let this = this.clone();
645 move |window, cx| {
646 this.update(cx, |this, cx| {
647 this.set_selected_agent(Agent::NativeAgent, window, cx)
648 })
649 .ok();
650 }
651 }),
652 )
653 .separator()
654 .map(|mut menu| {
655 let agent_server_store = agent_server_store.read(cx);
656 let registry_store = project::AgentRegistryStore::try_global(cx);
657 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
658
659 struct AgentMenuItem {
660 id: AgentId,
661 display_name: SharedString,
662 }
663
664 let agent_items = agent_server_store
665 .external_agents()
666 .map(|agent_id| {
667 let display_name = agent_server_store
668 .agent_display_name(agent_id)
669 .or_else(|| {
670 registry_store_ref
671 .as_ref()
672 .and_then(|store| store.agent(agent_id))
673 .map(|a| a.name().clone())
674 })
675 .unwrap_or_else(|| agent_id.0.clone());
676 AgentMenuItem {
677 id: agent_id.clone(),
678 display_name,
679 }
680 })
681 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
682 .collect::<Vec<_>>();
683
684 for item in &agent_items {
685 let mut entry = ContextMenuEntry::new(item.display_name.clone());
686
687 let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
688 registry_store_ref
689 .as_ref()
690 .and_then(|store| store.agent(&item.id))
691 .and_then(|a| a.icon_path().cloned())
692 });
693
694 if let Some(icon_path) = icon_path {
695 entry = entry.custom_icon_svg(icon_path);
696 } else {
697 entry = entry.icon(IconName::ZedAgent);
698 }
699
700 entry = entry.icon_color(Color::Muted).handler({
701 let this = this.clone();
702 let agent = Agent::Custom {
703 id: item.id.clone(),
704 };
705 move |window, cx| {
706 this.update(cx, |this, cx| {
707 this.set_selected_agent(agent.clone(), window, cx)
708 })
709 .ok();
710 }
711 });
712
713 menu = menu.item(entry);
714 }
715 menu
716 })
717 }))
718 })
719 .with_handle(self.selected_agent_menu.clone())
720 .anchor(gpui::Corner::TopRight)
721 .offset(gpui::Point {
722 x: px(1.0),
723 y: px(1.0),
724 })
725 }
726
727 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
728 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
729 let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
730 let header_height = platform_title_bar_height(window);
731
732 v_flex()
733 .child(
734 h_flex()
735 .h(header_height)
736 .mt_px()
737 .pb_px()
738 .when(traffic_lights, |this| {
739 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
740 })
741 .pr_1p5()
742 .border_b_1()
743 .border_color(cx.theme().colors().border)
744 .justify_between()
745 .child(
746 h_flex()
747 .gap_1p5()
748 .child(
749 IconButton::new("back", IconName::ArrowLeft)
750 .icon_size(IconSize::Small)
751 .tooltip(Tooltip::text("Back to Sidebar"))
752 .on_click(cx.listener(|this, _, window, cx| {
753 this.go_back(window, cx);
754 })),
755 )
756 .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()),
757 )
758 .child(self.render_agent_picker(cx)),
759 )
760 .child(
761 h_flex()
762 .h(Tab::container_height(cx))
763 .px_1p5()
764 .gap_1p5()
765 .border_b_1()
766 .border_color(cx.theme().colors().border)
767 .child(
768 h_flex().size_4().flex_none().justify_center().child(
769 Icon::new(IconName::MagnifyingGlass)
770 .size(IconSize::Small)
771 .color(Color::Muted),
772 ),
773 )
774 .child(self.filter_editor.clone())
775 .when(has_query, |this| {
776 this.child(
777 IconButton::new("clear_filter", IconName::Close)
778 .icon_size(IconSize::Small)
779 .tooltip(Tooltip::text("Clear Search"))
780 .on_click(cx.listener(|this, _, window, cx| {
781 this.reset_filter_editor_text(window, cx);
782 this.update_items(cx);
783 })),
784 )
785 }),
786 )
787 }
788}
789
790pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
791 let now = Utc::now();
792 let duration = now.signed_duration_since(entry_time);
793
794 let minutes = duration.num_minutes();
795 let hours = duration.num_hours();
796 let days = duration.num_days();
797 let weeks = days / 7;
798 let months = days / 30;
799
800 if minutes < 60 {
801 format!("{}m", minutes.max(1))
802 } else if hours < 24 {
803 format!("{}h", hours.max(1))
804 } else if days < 7 {
805 format!("{}d", days.max(1))
806 } else if weeks < 4 {
807 format!("{}w", weeks.max(1))
808 } else {
809 format!("{}mo", months.max(1))
810 }
811}
812
813impl Focusable for ThreadsArchiveView {
814 fn focus_handle(&self, _cx: &App) -> FocusHandle {
815 self.focus_handle.clone()
816 }
817}
818
819impl ThreadsArchiveView {
820 fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
821 archive_empty_state_message(self.history.is_some(), is_empty, has_query)
822 }
823}
824
825impl Render for ThreadsArchiveView {
826 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
827 let is_empty = self.items.is_empty();
828 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
829
830 let content = if self.is_loading {
831 v_flex()
832 .flex_1()
833 .justify_center()
834 .items_center()
835 .child(
836 Icon::new(IconName::LoadCircle)
837 .size(IconSize::Small)
838 .color(Color::Muted)
839 .with_rotate_animation(2),
840 )
841 .into_any_element()
842 } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
843 v_flex()
844 .flex_1()
845 .justify_center()
846 .items_center()
847 .child(
848 Label::new(message)
849 .size(LabelSize::Small)
850 .color(Color::Muted),
851 )
852 .into_any_element()
853 } else {
854 v_flex()
855 .flex_1()
856 .overflow_hidden()
857 .child(
858 list(
859 self.list_state.clone(),
860 cx.processor(Self::render_list_entry),
861 )
862 .flex_1()
863 .size_full(),
864 )
865 .vertical_scrollbar_for(&self.list_state, window, cx)
866 .into_any_element()
867 };
868
869 v_flex()
870 .key_context("ThreadsArchiveView")
871 .track_focus(&self.focus_handle)
872 .on_action(cx.listener(Self::select_next))
873 .on_action(cx.listener(Self::select_previous))
874 .on_action(cx.listener(Self::editor_move_down))
875 .on_action(cx.listener(Self::editor_move_up))
876 .on_action(cx.listener(Self::select_first))
877 .on_action(cx.listener(Self::select_last))
878 .on_action(cx.listener(Self::confirm))
879 .on_action(cx.listener(Self::remove_selected_thread))
880 .size_full()
881 .child(self.render_header(window, cx))
882 .child(content)
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::archive_empty_state_message;
889
890 #[test]
891 fn empty_state_message_returns_none_when_archive_has_items() {
892 assert_eq!(archive_empty_state_message(false, false, false), None);
893 assert_eq!(archive_empty_state_message(true, false, true), None);
894 }
895
896 #[test]
897 fn empty_state_message_distinguishes_unsupported_history() {
898 assert_eq!(
899 archive_empty_state_message(false, true, false),
900 Some("This agent does not support viewing archived threads.")
901 );
902 assert_eq!(
903 archive_empty_state_message(false, true, true),
904 Some("This agent does not support viewing archived threads.")
905 );
906 }
907
908 #[test]
909 fn empty_state_message_distinguishes_empty_history_and_search_results() {
910 assert_eq!(
911 archive_empty_state_message(true, true, false),
912 Some("No archived threads yet.")
913 );
914 assert_eq!(
915 archive_empty_state_message(true, true, true),
916 Some("No threads match your search.")
917 );
918 }
919}