threads_archive_view.rs

  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}