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::{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}