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