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::{AgentServerStore, ExternalAgentServerName};
 20use theme::ActiveTheme;
 21use ui::{
 22    ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem,
 23    PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
 24};
 25use util::ResultExt as _;
 26use zed_actions::editor::{MoveDown, MoveUp};
 27
 28#[derive(Clone)]
 29enum ArchiveListItem {
 30    BucketSeparator(TimeBucket),
 31    Entry {
 32        session: AgentSessionInfo,
 33        highlight_positions: Vec<usize>,
 34    },
 35}
 36
 37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 38enum TimeBucket {
 39    Today,
 40    Yesterday,
 41    ThisWeek,
 42    PastWeek,
 43    Older,
 44}
 45
 46impl TimeBucket {
 47    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
 48        if date == reference {
 49            return TimeBucket::Today;
 50        }
 51        if date == reference - TimeDelta::days(1) {
 52            return TimeBucket::Yesterday;
 53        }
 54        let week = date.iso_week();
 55        if reference.iso_week() == week {
 56            return TimeBucket::ThisWeek;
 57        }
 58        let last_week = (reference - TimeDelta::days(7)).iso_week();
 59        if week == last_week {
 60            return TimeBucket::PastWeek;
 61        }
 62        TimeBucket::Older
 63    }
 64
 65    fn label(&self) -> &'static str {
 66        match self {
 67            TimeBucket::Today => "Today",
 68            TimeBucket::Yesterday => "Yesterday",
 69            TimeBucket::ThisWeek => "This Week",
 70            TimeBucket::PastWeek => "Past Week",
 71            TimeBucket::Older => "Older",
 72        }
 73    }
 74}
 75
 76fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
 77    let query = query.to_lowercase();
 78    let text_lower = text.to_lowercase();
 79    let mut positions = Vec::new();
 80    let mut query_chars = query.chars().peekable();
 81    for (i, c) in text_lower.chars().enumerate() {
 82        if query_chars.peek() == Some(&c) {
 83            positions.push(i);
 84            query_chars.next();
 85        }
 86    }
 87    if query_chars.peek().is_none() {
 88        Some(positions)
 89    } else {
 90        None
 91    }
 92}
 93
 94pub enum ThreadsArchiveViewEvent {
 95    Close,
 96    OpenThread {
 97        agent: Agent,
 98        session_info: AgentSessionInfo,
 99    },
100}
101
102impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
103
104pub struct ThreadsArchiveView {
105    agent_connection_store: Entity<AgentConnectionStore>,
106    agent_server_store: Entity<AgentServerStore>,
107    thread_store: Entity<ThreadStore>,
108    fs: Arc<dyn Fs>,
109    history: Option<Entity<ThreadHistory>>,
110    _history_subscription: Subscription,
111    selected_agent: Agent,
112    focus_handle: FocusHandle,
113    list_state: ListState,
114    items: Vec<ArchiveListItem>,
115    selection: Option<usize>,
116    hovered_index: Option<usize>,
117    filter_editor: Entity<Editor>,
118    _subscriptions: Vec<gpui::Subscription>,
119    selected_agent_menu: PopoverMenuHandle<ContextMenu>,
120    _refresh_history_task: Task<()>,
121    is_loading: bool,
122}
123
124impl ThreadsArchiveView {
125    pub fn new(
126        agent_connection_store: Entity<AgentConnectionStore>,
127        agent_server_store: Entity<AgentServerStore>,
128        thread_store: Entity<ThreadStore>,
129        fs: Arc<dyn Fs>,
130        window: &mut Window,
131        cx: &mut Context<Self>,
132    ) -> Self {
133        let focus_handle = cx.focus_handle();
134
135        let filter_editor = cx.new(|cx| {
136            let mut editor = Editor::single_line(window, cx);
137            editor.set_placeholder_text("Search archive…", window, cx);
138            editor
139        });
140
141        let filter_editor_subscription =
142            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
143                if let editor::EditorEvent::BufferEdited = event {
144                    this.update_items(cx);
145                }
146            });
147
148        let mut this = Self {
149            agent_connection_store,
150            agent_server_store,
151            thread_store,
152            fs,
153            history: None,
154            _history_subscription: Subscription::new(|| {}),
155            selected_agent: Agent::NativeAgent,
156            focus_handle,
157            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
158            items: Vec::new(),
159            selection: None,
160            hovered_index: None,
161            filter_editor,
162            _subscriptions: vec![filter_editor_subscription],
163            selected_agent_menu: PopoverMenuHandle::default(),
164            _refresh_history_task: Task::ready(()),
165            is_loading: true,
166        };
167        this.set_selected_agent(Agent::NativeAgent, window, cx);
168        this
169    }
170
171    fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
172        self.selected_agent = agent.clone();
173        self.is_loading = true;
174        self.history = None;
175        self.items.clear();
176        self.selection = None;
177        self.list_state.reset(0);
178        self.reset_filter_editor_text(window, cx);
179
180        let server = agent.server(self.fs.clone(), self.thread_store.clone());
181        let connection = self
182            .agent_connection_store
183            .update(cx, |store, cx| store.request_connection(agent, server, cx));
184
185        let task = connection.read(cx).wait_for_connection();
186        self._refresh_history_task = cx.spawn(async move |this, cx| {
187            if let Some(state) = task.await.log_err() {
188                this.update(cx, |this, cx| this.set_history(state.history, cx))
189                    .ok();
190            }
191        });
192
193        cx.notify();
194    }
195
196    fn set_history(&mut self, history: Entity<ThreadHistory>, cx: &mut Context<Self>) {
197        self._history_subscription = cx.observe(&history, |this, _, cx| {
198            this.update_items(cx);
199        });
200        history.update(cx, |history, cx| {
201            history.refresh_full_history(cx);
202        });
203        self.history = Some(history);
204        self.is_loading = false;
205        self.update_items(cx);
206        cx.notify();
207    }
208
209    fn update_items(&mut self, cx: &mut Context<Self>) {
210        let Some(history) = self.history.as_ref() else {
211            return;
212        };
213
214        let sessions = history.read(cx).sessions().to_vec();
215        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
216        let today = Local::now().naive_local().date();
217
218        let mut items = Vec::with_capacity(sessions.len() + 5);
219        let mut current_bucket: Option<TimeBucket> = None;
220
221        for session in sessions {
222            let highlight_positions = if !query.is_empty() {
223                let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
224                match fuzzy_match_positions(&query, title) {
225                    Some(positions) => positions,
226                    None => continue,
227                }
228            } else {
229                Vec::new()
230            };
231
232            let entry_bucket = session
233                .updated_at
234                .map(|timestamp| {
235                    let entry_date = timestamp.with_timezone(&Local).naive_local().date();
236                    TimeBucket::from_dates(today, entry_date)
237                })
238                .unwrap_or(TimeBucket::Older);
239
240            if Some(entry_bucket) != current_bucket {
241                current_bucket = Some(entry_bucket);
242                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
243            }
244
245            items.push(ArchiveListItem::Entry {
246                session,
247                highlight_positions,
248            });
249        }
250
251        self.list_state.reset(items.len());
252        self.items = items;
253        cx.notify();
254    }
255
256    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
257        self.filter_editor.update(cx, |editor, cx| {
258            editor.set_text("", window, cx);
259        });
260    }
261
262    fn go_back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
263        self.reset_filter_editor_text(window, cx);
264        cx.emit(ThreadsArchiveViewEvent::Close);
265    }
266
267    fn open_thread(
268        &mut self,
269        session_info: AgentSessionInfo,
270        window: &mut Window,
271        cx: &mut Context<Self>,
272    ) {
273        self.selection = None;
274        self.reset_filter_editor_text(window, cx);
275        cx.emit(ThreadsArchiveViewEvent::OpenThread {
276            agent: self.selected_agent.clone(),
277            session_info,
278        });
279    }
280
281    fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
282        let Some(history) = &self.history else {
283            return;
284        };
285        if !history.read(cx).supports_delete() {
286            return;
287        }
288        let session_id = session_id.clone();
289        history.update(cx, |history, cx| {
290            history
291                .delete_session(&session_id, cx)
292                .detach_and_log_err(cx);
293        });
294    }
295
296    fn remove_selected_thread(
297        &mut self,
298        _: &RemoveSelectedThread,
299        _window: &mut Window,
300        cx: &mut Context<Self>,
301    ) {
302        let Some(ix) = self.selection else {
303            return;
304        };
305        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
306            return;
307        };
308        let session_id = session.session_id.clone();
309        self.delete_thread(&session_id, cx);
310    }
311
312    fn is_selectable_item(&self, ix: usize) -> bool {
313        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
314    }
315
316    fn find_next_selectable(&self, start: usize) -> Option<usize> {
317        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
318    }
319
320    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
321        (0..=start).rev().find(|&i| self.is_selectable_item(i))
322    }
323
324    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
325        self.select_next(&SelectNext, window, cx);
326    }
327
328    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
329        self.select_previous(&SelectPrevious, window, cx);
330    }
331
332    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
333        let next = match self.selection {
334            Some(ix) => self.find_next_selectable(ix + 1),
335            None => self.find_next_selectable(0),
336        };
337        if let Some(next) = next {
338            self.selection = Some(next);
339            self.list_state.scroll_to_reveal_item(next);
340            cx.notify();
341        }
342    }
343
344    fn select_previous(
345        &mut self,
346        _: &SelectPrevious,
347        _window: &mut Window,
348        cx: &mut Context<Self>,
349    ) {
350        let prev = match self.selection {
351            Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
352            None => {
353                let last = self.items.len().saturating_sub(1);
354                self.find_previous_selectable(last)
355            }
356            _ => return,
357        };
358        if let Some(prev) = prev {
359            self.selection = Some(prev);
360            self.list_state.scroll_to_reveal_item(prev);
361            cx.notify();
362        }
363    }
364
365    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
366        if let Some(first) = self.find_next_selectable(0) {
367            self.selection = Some(first);
368            self.list_state.scroll_to_reveal_item(first);
369            cx.notify();
370        }
371    }
372
373    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
374        let last = self.items.len().saturating_sub(1);
375        if let Some(last) = self.find_previous_selectable(last) {
376            self.selection = Some(last);
377            self.list_state.scroll_to_reveal_item(last);
378            cx.notify();
379        }
380    }
381
382    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
383        let Some(ix) = self.selection else { return };
384        let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
385            return;
386        };
387        self.open_thread(session.clone(), window, cx);
388    }
389
390    fn render_list_entry(
391        &mut self,
392        ix: usize,
393        _window: &mut Window,
394        cx: &mut Context<Self>,
395    ) -> AnyElement {
396        let Some(item) = self.items.get(ix) else {
397            return div().into_any_element();
398        };
399
400        match item {
401            ArchiveListItem::BucketSeparator(bucket) => div()
402                .w_full()
403                .px_2()
404                .pt_3()
405                .pb_1()
406                .child(
407                    Label::new(bucket.label())
408                        .size(LabelSize::Small)
409                        .color(Color::Muted),
410                )
411                .into_any_element(),
412            ArchiveListItem::Entry {
413                session,
414                highlight_positions,
415            } => {
416                let is_selected = self.selection == Some(ix);
417                let hovered = self.hovered_index == Some(ix);
418                let supports_delete = self
419                    .history
420                    .as_ref()
421                    .map(|h| h.read(cx).supports_delete())
422                    .unwrap_or(false);
423                let title: SharedString =
424                    session.title.clone().unwrap_or_else(|| "Untitled".into());
425                let session_info = session.clone();
426                let session_id_for_delete = session.session_id.clone();
427                let focus_handle = self.focus_handle.clone();
428                let highlight_positions = highlight_positions.clone();
429
430                let timestamp = session.created_at.or(session.updated_at).map(|entry_time| {
431                    let now = Utc::now();
432                    let duration = now.signed_duration_since(entry_time);
433
434                    let minutes = duration.num_minutes();
435                    let hours = duration.num_hours();
436                    let days = duration.num_days();
437                    let weeks = days / 7;
438                    let months = days / 30;
439
440                    if minutes < 60 {
441                        format!("{}m", minutes.max(1))
442                    } else if hours < 24 {
443                        format!("{}h", hours)
444                    } else if weeks < 4 {
445                        format!("{}w", weeks.max(1))
446                    } else {
447                        format!("{}mo", months.max(1))
448                    }
449                });
450
451                let id = SharedString::from(format!("archive-entry-{}", ix));
452
453                let title_label = if highlight_positions.is_empty() {
454                    Label::new(title)
455                        .size(LabelSize::Small)
456                        .truncate()
457                        .into_any_element()
458                } else {
459                    HighlightedLabel::new(title, highlight_positions)
460                        .size(LabelSize::Small)
461                        .truncate()
462                        .into_any_element()
463                };
464
465                ListItem::new(id)
466                    .toggle_state(is_selected)
467                    .child(
468                        h_flex()
469                            .min_w_0()
470                            .w_full()
471                            .py_1()
472                            .pl_0p5()
473                            .pr_1p5()
474                            .gap_2()
475                            .justify_between()
476                            .child(title_label)
477                            .when(!(hovered && supports_delete), |this| {
478                                this.when_some(timestamp, |this, ts| {
479                                    this.child(
480                                        Label::new(ts).size(LabelSize::Small).color(Color::Muted),
481                                    )
482                                })
483                            }),
484                    )
485                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
486                        if *is_hovered {
487                            this.hovered_index = Some(ix);
488                        } else if this.hovered_index == Some(ix) {
489                            this.hovered_index = None;
490                        }
491                        cx.notify();
492                    }))
493                    .end_slot::<IconButton>(if hovered && supports_delete {
494                        Some(
495                            IconButton::new("delete-thread", IconName::Trash)
496                                .icon_size(IconSize::Small)
497                                .icon_color(Color::Muted)
498                                .tooltip({
499                                    move |_window, cx| {
500                                        Tooltip::for_action_in(
501                                            "Delete Thread",
502                                            &RemoveSelectedThread,
503                                            &focus_handle,
504                                            cx,
505                                        )
506                                    }
507                                })
508                                .on_click(cx.listener(move |this, _, _, cx| {
509                                    this.delete_thread(&session_id_for_delete, cx);
510                                    cx.stop_propagation();
511                                })),
512                        )
513                    } else {
514                        None
515                    })
516                    .on_click(cx.listener(move |this, _, window, cx| {
517                        this.open_thread(session_info.clone(), window, cx);
518                    }))
519                    .into_any_element()
520            }
521        }
522    }
523
524    fn render_agent_picker(&self, cx: &mut Context<Self>) -> PopoverMenu<ContextMenu> {
525        let agent_server_store = self.agent_server_store.clone();
526
527        let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
528            (IconName::ChevronUp, Color::Accent)
529        } else {
530            (IconName::ChevronDown, Color::Muted)
531        };
532
533        let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent {
534            let store = agent_server_store.read(cx);
535            let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
536
537            if let Some(icon) = icon {
538                Icon::from_external_svg(icon)
539            } else {
540                Icon::new(IconName::Sparkle)
541            }
542            .color(Color::Muted)
543            .size(IconSize::Small)
544        } else {
545            Icon::new(IconName::ZedAgent)
546                .color(Color::Muted)
547                .size(IconSize::Small)
548        };
549
550        let this = cx.weak_entity();
551
552        PopoverMenu::new("agent_history_menu")
553            .trigger(
554                ButtonLike::new("selected_agent")
555                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
556                    .child(
557                        h_flex().gap_1().child(selected_agent_icon).child(
558                            Icon::new(chevron_icon)
559                                .color(icon_color)
560                                .size(IconSize::XSmall),
561                        ),
562                    ),
563            )
564            .menu(move |window, cx| {
565                Some(ContextMenu::build(window, cx, |menu, _window, cx| {
566                    menu.item(
567                        ContextMenuEntry::new("Zed Agent")
568                            .icon(IconName::ZedAgent)
569                            .icon_color(Color::Muted)
570                            .handler({
571                                let this = this.clone();
572                                move |window, cx| {
573                                    this.update(cx, |this, cx| {
574                                        this.set_selected_agent(Agent::NativeAgent, window, cx)
575                                    })
576                                    .ok();
577                                }
578                            }),
579                    )
580                    .separator()
581                    .map(|mut menu| {
582                        let agent_server_store = agent_server_store.read(cx);
583                        let registry_store = project::AgentRegistryStore::try_global(cx);
584                        let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
585
586                        struct AgentMenuItem {
587                            id: ExternalAgentServerName,
588                            display_name: SharedString,
589                        }
590
591                        let agent_items = agent_server_store
592                            .external_agents()
593                            .map(|name| {
594                                let display_name = agent_server_store
595                                    .agent_display_name(name)
596                                    .or_else(|| {
597                                        registry_store_ref
598                                            .as_ref()
599                                            .and_then(|store| store.agent(name.0.as_ref()))
600                                            .map(|a| a.name().clone())
601                                    })
602                                    .unwrap_or_else(|| name.0.clone());
603                                AgentMenuItem {
604                                    id: name.clone(),
605                                    display_name,
606                                }
607                            })
608                            .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
609                            .collect::<Vec<_>>();
610
611                        for item in &agent_items {
612                            let mut entry = ContextMenuEntry::new(item.display_name.clone());
613
614                            let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
615                                registry_store_ref
616                                    .as_ref()
617                                    .and_then(|store| store.agent(item.id.0.as_str()))
618                                    .and_then(|a| a.icon_path().cloned())
619                            });
620
621                            if let Some(icon_path) = icon_path {
622                                entry = entry.custom_icon_svg(icon_path);
623                            } else {
624                                entry = entry.icon(IconName::ZedAgent);
625                            }
626
627                            entry = entry.icon_color(Color::Muted).handler({
628                                let this = this.clone();
629                                let agent = Agent::Custom {
630                                    name: item.id.0.clone(),
631                                };
632                                move |window, cx| {
633                                    this.update(cx, |this, cx| {
634                                        this.set_selected_agent(agent.clone(), window, cx)
635                                    })
636                                    .ok();
637                                }
638                            });
639
640                            menu = menu.item(entry);
641                        }
642                        menu
643                    })
644                }))
645            })
646            .with_handle(self.selected_agent_menu.clone())
647            .anchor(gpui::Corner::TopRight)
648            .offset(gpui::Point {
649                x: px(1.0),
650                y: px(1.0),
651            })
652    }
653
654    fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
655        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
656
657        h_flex()
658            .h(Tab::container_height(cx))
659            .px_1()
660            .justify_between()
661            .border_b_1()
662            .border_color(cx.theme().colors().border)
663            .child(
664                h_flex()
665                    .flex_1()
666                    .w_full()
667                    .gap_1p5()
668                    .child(
669                        IconButton::new("back", IconName::ArrowLeft)
670                            .icon_size(IconSize::Small)
671                            .tooltip(Tooltip::text("Back to Sidebar"))
672                            .on_click(cx.listener(|this, _, window, cx| {
673                                this.go_back(window, cx);
674                            })),
675                    )
676                    .child(self.filter_editor.clone())
677                    .when(has_query, |this| {
678                        this.border_r_1().child(
679                            IconButton::new("clear_archive_filter", IconName::Close)
680                                .icon_size(IconSize::Small)
681                                .tooltip(Tooltip::text("Clear Search"))
682                                .on_click(cx.listener(|this, _, window, cx| {
683                                    this.reset_filter_editor_text(window, cx);
684                                    this.update_items(cx);
685                                })),
686                        )
687                    }),
688            )
689            .child(self.render_agent_picker(cx))
690    }
691}
692
693impl Focusable for ThreadsArchiveView {
694    fn focus_handle(&self, _cx: &App) -> FocusHandle {
695        self.focus_handle.clone()
696    }
697}
698
699impl Render for ThreadsArchiveView {
700    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
701        let is_empty = self.items.is_empty();
702        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
703
704        let content = if self.is_loading {
705            v_flex()
706                .flex_1()
707                .justify_center()
708                .items_center()
709                .child(
710                    Icon::new(IconName::LoadCircle)
711                        .size(IconSize::Small)
712                        .color(Color::Muted)
713                        .with_rotate_animation(2),
714                )
715                .into_any_element()
716        } else if is_empty && has_query {
717            v_flex()
718                .flex_1()
719                .justify_center()
720                .items_center()
721                .child(
722                    Label::new("No threads match your search.")
723                        .size(LabelSize::Small)
724                        .color(Color::Muted),
725                )
726                .into_any_element()
727        } else if is_empty {
728            v_flex()
729                .flex_1()
730                .justify_center()
731                .items_center()
732                .child(
733                    Label::new("No archived threads yet.")
734                        .size(LabelSize::Small)
735                        .color(Color::Muted),
736                )
737                .into_any_element()
738        } else {
739            v_flex()
740                .flex_1()
741                .overflow_hidden()
742                .child(
743                    list(
744                        self.list_state.clone(),
745                        cx.processor(Self::render_list_entry),
746                    )
747                    .flex_1()
748                    .size_full(),
749                )
750                .vertical_scrollbar_for(&self.list_state, window, cx)
751                .into_any_element()
752        };
753
754        v_flex()
755            .key_context("ThreadsArchiveView")
756            .track_focus(&self.focus_handle)
757            .on_action(cx.listener(Self::select_next))
758            .on_action(cx.listener(Self::select_previous))
759            .on_action(cx.listener(Self::editor_move_down))
760            .on_action(cx.listener(Self::editor_move_up))
761            .on_action(cx.listener(Self::select_first))
762            .on_action(cx.listener(Self::select_last))
763            .on_action(cx.listener(Self::confirm))
764            .on_action(cx.listener(Self::remove_selected_thread))
765            .size_full()
766            .bg(cx.theme().colors().surface_background)
767            .child(self.render_header(cx))
768            .child(content)
769    }
770}