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