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, ListItem,
23 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 OpenThread {
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 cx.notify();
280 }
281
282 fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
283 self.filter_editor.update(cx, |editor, cx| {
284 editor.set_text("", window, cx);
285 });
286 }
287
288 fn go_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
289 self.reset_filter_editor_text(window, cx);
290 cx.emit(ThreadsArchiveViewEvent::Close);
291 }
292
293 fn open_thread(
294 &mut self,
295 session_info: AgentSessionInfo,
296 window: &mut Window,
297 cx: &mut Context<Self>,
298 ) {
299 self.selection = None;
300 self.reset_filter_editor_text(window, cx);
301 cx.emit(ThreadsArchiveViewEvent::OpenThread {
302 agent: self.selected_agent.clone(),
303 session_info,
304 });
305 }
306
307 fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
308 let Some(history) = &self.history else {
309 return;
310 };
311 if !history.read(cx).supports_delete() {
312 return;
313 }
314 let session_id = session_id.clone();
315 history.update(cx, |history, cx| {
316 history
317 .delete_session(&session_id, cx)
318 .detach_and_log_err(cx);
319 });
320 }
321
322 fn remove_selected_thread(
323 &mut self,
324 _: &RemoveSelectedThread,
325 _window: &mut Window,
326 cx: &mut Context<Self>,
327 ) {
328 let Some(ix) = self.selection else {
329 return;
330 };
331 let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
332 return;
333 };
334 let session_id = session.session_id.clone();
335 self.delete_thread(&session_id, cx);
336 }
337
338 fn is_selectable_item(&self, ix: usize) -> bool {
339 matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
340 }
341
342 fn find_next_selectable(&self, start: usize) -> Option<usize> {
343 (start..self.items.len()).find(|&i| self.is_selectable_item(i))
344 }
345
346 fn find_previous_selectable(&self, start: usize) -> Option<usize> {
347 (0..=start).rev().find(|&i| self.is_selectable_item(i))
348 }
349
350 fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
351 self.select_next(&SelectNext, window, cx);
352 }
353
354 fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
355 self.select_previous(&SelectPrevious, window, cx);
356 }
357
358 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
359 let next = match self.selection {
360 Some(ix) => self.find_next_selectable(ix + 1),
361 None => self.find_next_selectable(0),
362 };
363 if let Some(next) = next {
364 self.selection = Some(next);
365 self.list_state.scroll_to_reveal_item(next);
366 cx.notify();
367 }
368 }
369
370 fn select_previous(
371 &mut self,
372 _: &SelectPrevious,
373 _window: &mut Window,
374 cx: &mut Context<Self>,
375 ) {
376 let prev = match self.selection {
377 Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
378 None => {
379 let last = self.items.len().saturating_sub(1);
380 self.find_previous_selectable(last)
381 }
382 _ => return,
383 };
384 if let Some(prev) = prev {
385 self.selection = Some(prev);
386 self.list_state.scroll_to_reveal_item(prev);
387 cx.notify();
388 }
389 }
390
391 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
392 if let Some(first) = self.find_next_selectable(0) {
393 self.selection = Some(first);
394 self.list_state.scroll_to_reveal_item(first);
395 cx.notify();
396 }
397 }
398
399 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
400 let last = self.items.len().saturating_sub(1);
401 if let Some(last) = self.find_previous_selectable(last) {
402 self.selection = Some(last);
403 self.list_state.scroll_to_reveal_item(last);
404 cx.notify();
405 }
406 }
407
408 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
409 let Some(ix) = self.selection else { return };
410 let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
411 return;
412 };
413 self.open_thread(session.clone(), window, cx);
414 }
415
416 fn render_list_entry(
417 &mut self,
418 ix: usize,
419 _window: &mut Window,
420 cx: &mut Context<Self>,
421 ) -> AnyElement {
422 let Some(item) = self.items.get(ix) else {
423 return div().into_any_element();
424 };
425
426 match item {
427 ArchiveListItem::BucketSeparator(bucket) => div()
428 .w_full()
429 .px_2()
430 .pt_3()
431 .pb_1()
432 .child(
433 Label::new(bucket.label())
434 .size(LabelSize::Small)
435 .color(Color::Muted),
436 )
437 .into_any_element(),
438 ArchiveListItem::Entry {
439 session,
440 highlight_positions,
441 } => {
442 let is_selected = self.selection == Some(ix);
443 let hovered = self.hovered_index == Some(ix);
444 let supports_delete = self
445 .history
446 .as_ref()
447 .map(|h| h.read(cx).supports_delete())
448 .unwrap_or(false);
449 let title: SharedString =
450 session.title.clone().unwrap_or_else(|| "Untitled".into());
451 let session_info = session.clone();
452 let session_id_for_delete = session.session_id.clone();
453 let focus_handle = self.focus_handle.clone();
454 let highlight_positions = highlight_positions.clone();
455
456 let timestamp = session
457 .created_at
458 .or(session.updated_at)
459 .map(format_history_entry_timestamp);
460
461 let id = SharedString::from(format!("archive-entry-{}", ix));
462
463 let title_label = if highlight_positions.is_empty() {
464 Label::new(title)
465 .size(LabelSize::Small)
466 .truncate()
467 .into_any_element()
468 } else {
469 HighlightedLabel::new(title, highlight_positions)
470 .size(LabelSize::Small)
471 .truncate()
472 .into_any_element()
473 };
474
475 ListItem::new(id)
476 .toggle_state(is_selected)
477 .child(
478 h_flex()
479 .min_w_0()
480 .w_full()
481 .py_1()
482 .pl_0p5()
483 .pr_1p5()
484 .gap_2()
485 .justify_between()
486 .child(title_label)
487 .when(!(hovered && supports_delete), |this| {
488 this.when_some(timestamp, |this, ts| {
489 this.child(
490 Label::new(ts).size(LabelSize::Small).color(Color::Muted),
491 )
492 })
493 }),
494 )
495 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
496 if *is_hovered {
497 this.hovered_index = Some(ix);
498 } else if this.hovered_index == Some(ix) {
499 this.hovered_index = None;
500 }
501 cx.notify();
502 }))
503 .end_slot::<IconButton>(if hovered && supports_delete {
504 Some(
505 IconButton::new("delete-thread", IconName::Trash)
506 .icon_size(IconSize::Small)
507 .icon_color(Color::Muted)
508 .tooltip({
509 move |_window, cx| {
510 Tooltip::for_action_in(
511 "Delete Thread",
512 &RemoveSelectedThread,
513 &focus_handle,
514 cx,
515 )
516 }
517 })
518 .on_click(cx.listener(move |this, _, _, cx| {
519 this.delete_thread(&session_id_for_delete, cx);
520 cx.stop_propagation();
521 })),
522 )
523 } else {
524 None
525 })
526 .on_click(cx.listener(move |this, _, window, cx| {
527 this.open_thread(session_info.clone(), window, cx);
528 }))
529 .into_any_element()
530 }
531 }
532 }
533
534 fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
535 let agent_server_store = self.agent_server_store.clone();
536
537 let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
538 (IconName::ChevronUp, Color::Accent)
539 } else {
540 (IconName::ChevronDown, Color::Muted)
541 };
542
543 let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
544 let store = agent_server_store.read(cx);
545 let icon = store.agent_icon(&id);
546
547 if let Some(icon) = icon {
548 Icon::from_external_svg(icon)
549 } else {
550 Icon::new(IconName::Sparkle)
551 }
552 .color(Color::Muted)
553 .size(IconSize::Small)
554 } else {
555 Icon::new(IconName::ZedAgent)
556 .color(Color::Muted)
557 .size(IconSize::Small)
558 };
559
560 let this = cx.weak_entity();
561
562 PopoverMenu::new("agent_history_menu")
563 .trigger(
564 ButtonLike::new("selected_agent")
565 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
566 .child(
567 h_flex().gap_1().child(selected_agent_icon).child(
568 Icon::new(chevron_icon)
569 .color(icon_color)
570 .size(IconSize::XSmall),
571 ),
572 ),
573 )
574 .menu(move |window, cx| {
575 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
576 menu.item(
577 ContextMenuEntry::new("Zed Agent")
578 .icon(IconName::ZedAgent)
579 .icon_color(Color::Muted)
580 .handler({
581 let this = this.clone();
582 move |window, cx| {
583 this.update(cx, |this, cx| {
584 this.set_selected_agent(Agent::NativeAgent, window, cx)
585 })
586 .ok();
587 }
588 }),
589 )
590 .separator()
591 .map(|mut menu| {
592 let agent_server_store = agent_server_store.read(cx);
593 let registry_store = project::AgentRegistryStore::try_global(cx);
594 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
595
596 struct AgentMenuItem {
597 id: AgentId,
598 display_name: SharedString,
599 }
600
601 let agent_items = agent_server_store
602 .external_agents()
603 .map(|agent_id| {
604 let display_name = agent_server_store
605 .agent_display_name(agent_id)
606 .or_else(|| {
607 registry_store_ref
608 .as_ref()
609 .and_then(|store| store.agent(agent_id))
610 .map(|a| a.name().clone())
611 })
612 .unwrap_or_else(|| agent_id.0.clone());
613 AgentMenuItem {
614 id: agent_id.clone(),
615 display_name,
616 }
617 })
618 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
619 .collect::<Vec<_>>();
620
621 for item in &agent_items {
622 let mut entry = ContextMenuEntry::new(item.display_name.clone());
623
624 let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
625 registry_store_ref
626 .as_ref()
627 .and_then(|store| store.agent(&item.id))
628 .and_then(|a| a.icon_path().cloned())
629 });
630
631 if let Some(icon_path) = icon_path {
632 entry = entry.custom_icon_svg(icon_path);
633 } else {
634 entry = entry.icon(IconName::ZedAgent);
635 }
636
637 entry = entry.icon_color(Color::Muted).handler({
638 let this = this.clone();
639 let agent = Agent::Custom {
640 id: item.id.clone(),
641 };
642 move |window, cx| {
643 this.update(cx, |this, cx| {
644 this.set_selected_agent(agent.clone(), window, cx)
645 })
646 .ok();
647 }
648 });
649
650 menu = menu.item(entry);
651 }
652 menu
653 })
654 }))
655 })
656 .with_handle(self.selected_agent_menu.clone())
657 .anchor(gpui::Corner::TopRight)
658 .offset(gpui::Point {
659 x: px(1.0),
660 y: px(1.0),
661 })
662 }
663
664 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
665 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
666 let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
667 let header_height = platform_title_bar_height(window);
668
669 v_flex()
670 .child(
671 h_flex()
672 .h(header_height)
673 .mt_px()
674 .pb_px()
675 .when(traffic_lights, |this| {
676 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
677 })
678 .pr_1p5()
679 .border_b_1()
680 .border_color(cx.theme().colors().border)
681 .justify_between()
682 .child(
683 h_flex()
684 .gap_1p5()
685 .child(
686 IconButton::new("back", IconName::ArrowLeft)
687 .icon_size(IconSize::Small)
688 .tooltip(Tooltip::text("Back to Sidebar"))
689 .on_click(cx.listener(|this, _, window, cx| {
690 this.go_back(window, cx);
691 })),
692 )
693 .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()),
694 )
695 .child(self.render_agent_picker(cx)),
696 )
697 .child(
698 h_flex()
699 .h(Tab::container_height(cx))
700 .p_2()
701 .pr_1p5()
702 .gap_1p5()
703 .border_b_1()
704 .border_color(cx.theme().colors().border)
705 .child(
706 Icon::new(IconName::MagnifyingGlass)
707 .size(IconSize::Small)
708 .color(Color::Muted),
709 )
710 .child(self.filter_editor.clone())
711 .when(has_query, |this| {
712 this.child(
713 IconButton::new("clear_filter", IconName::Close)
714 .icon_size(IconSize::Small)
715 .tooltip(Tooltip::text("Clear Search"))
716 .on_click(cx.listener(|this, _, window, cx| {
717 this.reset_filter_editor_text(window, cx);
718 this.update_items(cx);
719 })),
720 )
721 }),
722 )
723 }
724}
725
726pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
727 let now = Utc::now();
728 let duration = now.signed_duration_since(entry_time);
729
730 let minutes = duration.num_minutes();
731 let hours = duration.num_hours();
732 let days = duration.num_days();
733 let weeks = days / 7;
734 let months = days / 30;
735
736 if minutes < 60 {
737 format!("{}m", minutes.max(1))
738 } else if hours < 24 {
739 format!("{}h", hours.max(1))
740 } else if days < 7 {
741 format!("{}d", days.max(1))
742 } else if weeks < 4 {
743 format!("{}w", weeks.max(1))
744 } else {
745 format!("{}mo", months.max(1))
746 }
747}
748
749impl Focusable for ThreadsArchiveView {
750 fn focus_handle(&self, _cx: &App) -> FocusHandle {
751 self.focus_handle.clone()
752 }
753}
754
755impl ThreadsArchiveView {
756 fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
757 archive_empty_state_message(self.history.is_some(), is_empty, has_query)
758 }
759}
760
761impl Render for ThreadsArchiveView {
762 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
763 let is_empty = self.items.is_empty();
764 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
765
766 let content = if self.is_loading {
767 v_flex()
768 .flex_1()
769 .justify_center()
770 .items_center()
771 .child(
772 Icon::new(IconName::LoadCircle)
773 .size(IconSize::Small)
774 .color(Color::Muted)
775 .with_rotate_animation(2),
776 )
777 .into_any_element()
778 } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
779 v_flex()
780 .flex_1()
781 .justify_center()
782 .items_center()
783 .child(
784 Label::new(message)
785 .size(LabelSize::Small)
786 .color(Color::Muted),
787 )
788 .into_any_element()
789 } else {
790 v_flex()
791 .flex_1()
792 .overflow_hidden()
793 .child(
794 list(
795 self.list_state.clone(),
796 cx.processor(Self::render_list_entry),
797 )
798 .flex_1()
799 .size_full(),
800 )
801 .vertical_scrollbar_for(&self.list_state, window, cx)
802 .into_any_element()
803 };
804
805 v_flex()
806 .key_context("ThreadsArchiveView")
807 .track_focus(&self.focus_handle)
808 .on_action(cx.listener(Self::select_next))
809 .on_action(cx.listener(Self::select_previous))
810 .on_action(cx.listener(Self::editor_move_down))
811 .on_action(cx.listener(Self::editor_move_up))
812 .on_action(cx.listener(Self::select_first))
813 .on_action(cx.listener(Self::select_last))
814 .on_action(cx.listener(Self::confirm))
815 .on_action(cx.listener(Self::remove_selected_thread))
816 .size_full()
817 .child(self.render_header(window, cx))
818 .child(content)
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::archive_empty_state_message;
825
826 #[test]
827 fn empty_state_message_returns_none_when_archive_has_items() {
828 assert_eq!(archive_empty_state_message(false, false, false), None);
829 assert_eq!(archive_empty_state_message(true, false, true), None);
830 }
831
832 #[test]
833 fn empty_state_message_distinguishes_unsupported_history() {
834 assert_eq!(
835 archive_empty_state_message(false, true, false),
836 Some("This agent does not support viewing archived threads.")
837 );
838 assert_eq!(
839 archive_empty_state_message(false, true, true),
840 Some("This agent does not support viewing archived threads.")
841 );
842 }
843
844 #[test]
845 fn empty_state_message_distinguishes_empty_history_and_search_results() {
846 assert_eq!(
847 archive_empty_state_message(true, true, false),
848 Some("No archived threads yet.")
849 );
850 assert_eq!(
851 archive_empty_state_message(true, true, true),
852 Some("No threads match your search.")
853 );
854 }
855}