threads_archive_view.rs

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