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::{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.created_at.or(session.updated_at).map(|entry_time| {
457 let now = Utc::now();
458 let duration = now.signed_duration_since(entry_time);
459
460 let minutes = duration.num_minutes();
461 let hours = duration.num_hours();
462 let days = duration.num_days();
463 let weeks = days / 7;
464 let months = days / 30;
465
466 if minutes < 60 {
467 format!("{}m", minutes.max(1))
468 } else if hours < 24 {
469 format!("{}h", hours)
470 } else if weeks < 4 {
471 format!("{}w", weeks.max(1))
472 } else {
473 format!("{}mo", months.max(1))
474 }
475 });
476
477 let id = SharedString::from(format!("archive-entry-{}", ix));
478
479 let title_label = if highlight_positions.is_empty() {
480 Label::new(title)
481 .size(LabelSize::Small)
482 .truncate()
483 .into_any_element()
484 } else {
485 HighlightedLabel::new(title, highlight_positions)
486 .size(LabelSize::Small)
487 .truncate()
488 .into_any_element()
489 };
490
491 ListItem::new(id)
492 .toggle_state(is_selected)
493 .child(
494 h_flex()
495 .min_w_0()
496 .w_full()
497 .py_1()
498 .pl_0p5()
499 .pr_1p5()
500 .gap_2()
501 .justify_between()
502 .child(title_label)
503 .when(!(hovered && supports_delete), |this| {
504 this.when_some(timestamp, |this, ts| {
505 this.child(
506 Label::new(ts).size(LabelSize::Small).color(Color::Muted),
507 )
508 })
509 }),
510 )
511 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
512 if *is_hovered {
513 this.hovered_index = Some(ix);
514 } else if this.hovered_index == Some(ix) {
515 this.hovered_index = None;
516 }
517 cx.notify();
518 }))
519 .end_slot::<IconButton>(if hovered && supports_delete {
520 Some(
521 IconButton::new("delete-thread", IconName::Trash)
522 .icon_size(IconSize::Small)
523 .icon_color(Color::Muted)
524 .tooltip({
525 move |_window, cx| {
526 Tooltip::for_action_in(
527 "Delete Thread",
528 &RemoveSelectedThread,
529 &focus_handle,
530 cx,
531 )
532 }
533 })
534 .on_click(cx.listener(move |this, _, _, cx| {
535 this.delete_thread(&session_id_for_delete, cx);
536 cx.stop_propagation();
537 })),
538 )
539 } else {
540 None
541 })
542 .on_click(cx.listener(move |this, _, window, cx| {
543 this.open_thread(session_info.clone(), window, cx);
544 }))
545 .into_any_element()
546 }
547 }
548 }
549
550 fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
551 let agent_server_store = self.agent_server_store.clone();
552
553 let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
554 (IconName::ChevronUp, Color::Accent)
555 } else {
556 (IconName::ChevronDown, Color::Muted)
557 };
558
559 let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent {
560 let store = agent_server_store.read(cx);
561 let icon = store.agent_icon(&id);
562
563 if let Some(icon) = icon {
564 Icon::from_external_svg(icon)
565 } else {
566 Icon::new(IconName::Sparkle)
567 }
568 .color(Color::Muted)
569 .size(IconSize::Small)
570 } else {
571 Icon::new(IconName::ZedAgent)
572 .color(Color::Muted)
573 .size(IconSize::Small)
574 };
575
576 let this = cx.weak_entity();
577
578 PopoverMenu::new("agent_history_menu")
579 .trigger(
580 ButtonLike::new("selected_agent")
581 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
582 .child(
583 h_flex().gap_1().child(selected_agent_icon).child(
584 Icon::new(chevron_icon)
585 .color(icon_color)
586 .size(IconSize::XSmall),
587 ),
588 ),
589 )
590 .menu(move |window, cx| {
591 Some(ContextMenu::build(window, cx, |menu, _window, cx| {
592 menu.item(
593 ContextMenuEntry::new("Zed Agent")
594 .icon(IconName::ZedAgent)
595 .icon_color(Color::Muted)
596 .handler({
597 let this = this.clone();
598 move |window, cx| {
599 this.update(cx, |this, cx| {
600 this.set_selected_agent(Agent::NativeAgent, window, cx)
601 })
602 .ok();
603 }
604 }),
605 )
606 .separator()
607 .map(|mut menu| {
608 let agent_server_store = agent_server_store.read(cx);
609 let registry_store = project::AgentRegistryStore::try_global(cx);
610 let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
611
612 struct AgentMenuItem {
613 id: AgentId,
614 display_name: SharedString,
615 }
616
617 let agent_items = agent_server_store
618 .external_agents()
619 .map(|agent_id| {
620 let display_name = agent_server_store
621 .agent_display_name(agent_id)
622 .or_else(|| {
623 registry_store_ref
624 .as_ref()
625 .and_then(|store| store.agent(agent_id))
626 .map(|a| a.name().clone())
627 })
628 .unwrap_or_else(|| agent_id.0.clone());
629 AgentMenuItem {
630 id: agent_id.clone(),
631 display_name,
632 }
633 })
634 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
635 .collect::<Vec<_>>();
636
637 for item in &agent_items {
638 let mut entry = ContextMenuEntry::new(item.display_name.clone());
639
640 let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
641 registry_store_ref
642 .as_ref()
643 .and_then(|store| store.agent(&item.id))
644 .and_then(|a| a.icon_path().cloned())
645 });
646
647 if let Some(icon_path) = icon_path {
648 entry = entry.custom_icon_svg(icon_path);
649 } else {
650 entry = entry.icon(IconName::ZedAgent);
651 }
652
653 entry = entry.icon_color(Color::Muted).handler({
654 let this = this.clone();
655 let agent = Agent::Custom {
656 id: item.id.clone(),
657 };
658 move |window, cx| {
659 this.update(cx, |this, cx| {
660 this.set_selected_agent(agent.clone(), window, cx)
661 })
662 .ok();
663 }
664 });
665
666 menu = menu.item(entry);
667 }
668 menu
669 })
670 }))
671 })
672 .with_handle(self.selected_agent_menu.clone())
673 .anchor(gpui::Corner::TopRight)
674 .offset(gpui::Point {
675 x: px(1.0),
676 y: px(1.0),
677 })
678 }
679
680 fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
681 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
682 let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
683 let header_height = platform_title_bar_height(window);
684
685 v_flex()
686 .child(
687 h_flex()
688 .h(header_height)
689 .mt_px()
690 .pb_px()
691 .when(traffic_lights, |this| {
692 this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
693 })
694 .pr_1p5()
695 .border_b_1()
696 .border_color(cx.theme().colors().border)
697 .justify_between()
698 .child(
699 h_flex()
700 .gap_1p5()
701 .child(
702 IconButton::new("back", IconName::ArrowLeft)
703 .icon_size(IconSize::Small)
704 .tooltip(Tooltip::text("Back to Sidebar"))
705 .on_click(cx.listener(|this, _, window, cx| {
706 this.go_back(window, cx);
707 })),
708 )
709 .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()),
710 )
711 .child(self.render_agent_picker(cx)),
712 )
713 .child(
714 h_flex()
715 .h(Tab::container_height(cx))
716 .p_2()
717 .pr_1p5()
718 .gap_1p5()
719 .border_b_1()
720 .border_color(cx.theme().colors().border)
721 .child(
722 Icon::new(IconName::MagnifyingGlass)
723 .size(IconSize::Small)
724 .color(Color::Muted),
725 )
726 .child(self.filter_editor.clone())
727 .when(has_query, |this| {
728 this.child(
729 IconButton::new("clear_filter", IconName::Close)
730 .icon_size(IconSize::Small)
731 .tooltip(Tooltip::text("Clear Search"))
732 .on_click(cx.listener(|this, _, window, cx| {
733 this.reset_filter_editor_text(window, cx);
734 this.update_items(cx);
735 })),
736 )
737 }),
738 )
739 }
740}
741
742impl Focusable for ThreadsArchiveView {
743 fn focus_handle(&self, _cx: &App) -> FocusHandle {
744 self.focus_handle.clone()
745 }
746}
747
748impl ThreadsArchiveView {
749 fn empty_state_message(&self, is_empty: bool, has_query: bool) -> Option<&'static str> {
750 archive_empty_state_message(self.history.is_some(), is_empty, has_query)
751 }
752}
753
754impl Render for ThreadsArchiveView {
755 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
756 let is_empty = self.items.is_empty();
757 let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
758
759 let content = if self.is_loading {
760 v_flex()
761 .flex_1()
762 .justify_center()
763 .items_center()
764 .child(
765 Icon::new(IconName::LoadCircle)
766 .size(IconSize::Small)
767 .color(Color::Muted)
768 .with_rotate_animation(2),
769 )
770 .into_any_element()
771 } else if let Some(message) = self.empty_state_message(is_empty, has_query) {
772 v_flex()
773 .flex_1()
774 .justify_center()
775 .items_center()
776 .child(
777 Label::new(message)
778 .size(LabelSize::Small)
779 .color(Color::Muted),
780 )
781 .into_any_element()
782 } else {
783 v_flex()
784 .flex_1()
785 .overflow_hidden()
786 .child(
787 list(
788 self.list_state.clone(),
789 cx.processor(Self::render_list_entry),
790 )
791 .flex_1()
792 .size_full(),
793 )
794 .vertical_scrollbar_for(&self.list_state, window, cx)
795 .into_any_element()
796 };
797
798 v_flex()
799 .key_context("ThreadsArchiveView")
800 .track_focus(&self.focus_handle)
801 .on_action(cx.listener(Self::select_next))
802 .on_action(cx.listener(Self::select_previous))
803 .on_action(cx.listener(Self::editor_move_down))
804 .on_action(cx.listener(Self::editor_move_up))
805 .on_action(cx.listener(Self::select_first))
806 .on_action(cx.listener(Self::select_last))
807 .on_action(cx.listener(Self::confirm))
808 .on_action(cx.listener(Self::remove_selected_thread))
809 .size_full()
810 .child(self.render_header(window, cx))
811 .child(content)
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::archive_empty_state_message;
818
819 #[test]
820 fn empty_state_message_returns_none_when_archive_has_items() {
821 assert_eq!(archive_empty_state_message(false, false, false), None);
822 assert_eq!(archive_empty_state_message(true, false, true), None);
823 }
824
825 #[test]
826 fn empty_state_message_distinguishes_unsupported_history() {
827 assert_eq!(
828 archive_empty_state_message(false, true, false),
829 Some("This agent does not support viewing archived threads.")
830 );
831 assert_eq!(
832 archive_empty_state_message(false, true, true),
833 Some("This agent does not support viewing archived threads.")
834 );
835 }
836
837 #[test]
838 fn empty_state_message_distinguishes_empty_history_and_search_results() {
839 assert_eq!(
840 archive_empty_state_message(true, true, false),
841 Some("No archived threads yet.")
842 );
843 assert_eq!(
844 archive_empty_state_message(true, true, true),
845 Some("No threads match your search.")
846 );
847 }
848}