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