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            .collect::<Vec<_>>();
218
219        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
220        let today = Local::now().naive_local().date();
221
222        let mut items = Vec::with_capacity(sessions.len() + 5);
223        let mut current_bucket: Option<TimeBucket> = None;
224
225        for session in sessions {
226            let highlight_positions = if !query.is_empty() {
227                match fuzzy_match_positions(&query, &session.title) {
228                    Some(positions) => positions,
229                    None => continue,
230                }
231            } else {
232                Vec::new()
233            };
234
235            let entry_bucket = {
236                let entry_date = session
237                    .created_at
238                    .unwrap_or(session.updated_at)
239                    .with_timezone(&Local)
240                    .naive_local()
241                    .date();
242                TimeBucket::from_dates(today, entry_date)
243            };
244
245            if Some(entry_bucket) != current_bucket {
246                current_bucket = Some(entry_bucket);
247                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
248            }
249
250            items.push(ArchiveListItem::Entry {
251                thread: session,
252                highlight_positions,
253            });
254        }
255
256        let preserve = self.preserve_selection_on_next_update;
257        self.preserve_selection_on_next_update = false;
258
259        let saved_scroll = if preserve {
260            Some(self.list_state.logical_scroll_top())
261        } else {
262            None
263        };
264
265        self.list_state.reset(items.len());
266        self.items = items;
267        self.hovered_index = None;
268
269        if let Some(scroll_top) = saved_scroll {
270            self.list_state.scroll_to(scroll_top);
271
272            if let Some(ix) = self.selection {
273                let next = self.find_next_selectable(ix).or_else(|| {
274                    ix.checked_sub(1)
275                        .and_then(|i| self.find_previous_selectable(i))
276                });
277                self.selection = next;
278                if let Some(next) = next {
279                    self.list_state.scroll_to_reveal_item(next);
280                }
281            }
282        } else {
283            self.selection = None;
284        }
285
286        cx.notify();
287    }
288
289    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
290        self.filter_editor.update(cx, |editor, cx| {
291            editor.set_text("", window, cx);
292        });
293    }
294
295    fn unarchive_thread(
296        &mut self,
297        thread: ThreadMetadata,
298        window: &mut Window,
299        cx: &mut Context<Self>,
300    ) {
301        self.selection = None;
302        self.reset_filter_editor_text(window, cx);
303        cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
304    }
305
306    fn is_selectable_item(&self, ix: usize) -> bool {
307        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
308    }
309
310    fn find_next_selectable(&self, start: usize) -> Option<usize> {
311        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
312    }
313
314    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
315        (0..=start).rev().find(|&i| self.is_selectable_item(i))
316    }
317
318    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
319        self.select_next(&SelectNext, window, cx);
320        if self.selection.is_some() {
321            self.focus_handle.focus(window, cx);
322        }
323    }
324
325    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
326        self.select_previous(&SelectPrevious, window, cx);
327        if self.selection.is_some() {
328            self.focus_handle.focus(window, cx);
329        }
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(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
345        match self.selection {
346            Some(ix) => {
347                if let Some(prev) = (ix > 0)
348                    .then(|| self.find_previous_selectable(ix - 1))
349                    .flatten()
350                {
351                    self.selection = Some(prev);
352                    self.list_state.scroll_to_reveal_item(prev);
353                } else {
354                    self.selection = None;
355                    self.focus_filter_editor(window, cx);
356                }
357                cx.notify();
358            }
359            None => {
360                let last = self.items.len().saturating_sub(1);
361                if let Some(prev) = self.find_previous_selectable(last) {
362                    self.selection = Some(prev);
363                    self.list_state.scroll_to_reveal_item(prev);
364                    cx.notify();
365                }
366            }
367        }
368    }
369
370    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
371        if let Some(first) = self.find_next_selectable(0) {
372            self.selection = Some(first);
373            self.list_state.scroll_to_reveal_item(first);
374            cx.notify();
375        }
376    }
377
378    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
379        let last = self.items.len().saturating_sub(1);
380        if let Some(last) = self.find_previous_selectable(last) {
381            self.selection = Some(last);
382            self.list_state.scroll_to_reveal_item(last);
383            cx.notify();
384        }
385    }
386
387    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
388        let Some(ix) = self.selection else { return };
389        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
390            return;
391        };
392
393        if thread.folder_paths.is_empty() {
394            return;
395        }
396
397        self.unarchive_thread(thread.clone(), window, cx);
398    }
399
400    fn render_list_entry(
401        &mut self,
402        ix: usize,
403        _window: &mut Window,
404        cx: &mut Context<Self>,
405    ) -> AnyElement {
406        let Some(item) = self.items.get(ix) else {
407            return div().into_any_element();
408        };
409
410        match item {
411            ArchiveListItem::BucketSeparator(bucket) => div()
412                .w_full()
413                .px_2p5()
414                .pt_3()
415                .pb_1()
416                .child(
417                    Label::new(bucket.label())
418                        .size(LabelSize::Small)
419                        .color(Color::Muted),
420                )
421                .into_any_element(),
422            ArchiveListItem::Entry {
423                thread,
424                highlight_positions,
425            } => {
426                let id = SharedString::from(format!("archive-entry-{}", ix));
427
428                let is_focused = self.selection == Some(ix);
429                let is_hovered = self.hovered_index == Some(ix);
430
431                let focus_handle = self.focus_handle.clone();
432
433                let timestamp =
434                    format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
435
436                let icon_from_external_svg = self
437                    .agent_server_store
438                    .upgrade()
439                    .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
440
441                let icon = if thread.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
442                    IconName::ZedAgent
443                } else {
444                    IconName::Sparkle
445                };
446
447                ThreadItem::new(id, thread.title.clone())
448                    .icon(icon)
449                    .when_some(icon_from_external_svg, |this, svg| {
450                        this.custom_icon_from_external_svg(svg)
451                    })
452                    .timestamp(timestamp)
453                    .highlight_positions(highlight_positions.clone())
454                    .project_paths(thread.folder_paths.paths_owned())
455                    .focused(is_focused)
456                    .hovered(is_hovered)
457                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
458                        if *is_hovered {
459                            this.hovered_index = Some(ix);
460                        } else if this.hovered_index == Some(ix) {
461                            this.hovered_index = None;
462                        }
463                        cx.notify();
464                    }))
465                    .action_slot(
466                        IconButton::new("delete-thread", IconName::Trash)
467                            .style(ButtonStyle::Filled)
468                            .icon_size(IconSize::Small)
469                            .icon_color(Color::Muted)
470                            .tooltip({
471                                move |_window, cx| {
472                                    Tooltip::for_action_in(
473                                        "Delete Thread",
474                                        &RemoveSelectedThread,
475                                        &focus_handle,
476                                        cx,
477                                    )
478                                }
479                            })
480                            .on_click({
481                                let agent = thread.agent_id.clone();
482                                let session_id = thread.session_id.clone();
483                                cx.listener(move |this, _, _, cx| {
484                                    this.delete_thread(session_id.clone(), agent.clone(), cx);
485                                    cx.stop_propagation();
486                                })
487                            }),
488                    )
489                    .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
490                    .on_click({
491                        let thread = thread.clone();
492                        cx.listener(move |this, _, window, cx| {
493                            this.unarchive_thread(thread.clone(), window, cx);
494                        })
495                    })
496                    .into_any_element()
497            }
498        }
499    }
500
501    fn remove_selected_thread(
502        &mut self,
503        _: &RemoveSelectedThread,
504        _window: &mut Window,
505        cx: &mut Context<Self>,
506    ) {
507        let Some(ix) = self.selection else { return };
508        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
509            return;
510        };
511
512        self.preserve_selection_on_next_update = true;
513        self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
514    }
515
516    fn delete_thread(
517        &mut self,
518        session_id: acp::SessionId,
519        agent: AgentId,
520        cx: &mut Context<Self>,
521    ) {
522        ThreadMetadataStore::global(cx)
523            .update(cx, |store, cx| store.delete(session_id.clone(), cx));
524
525        let agent = Agent::from(agent);
526
527        let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
528            return;
529        };
530        let fs = <dyn Fs>::global(cx);
531
532        let task = agent_connection_store.update(cx, |store, cx| {
533            store
534                .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
535                .read(cx)
536                .wait_for_connection()
537        });
538        cx.spawn(async move |_this, cx| {
539            let state = task.await?;
540            let task = cx.update(|cx| {
541                if let Some(list) = state.connection.session_list(cx) {
542                    list.delete_session(&session_id, cx)
543                } else {
544                    Task::ready(Ok(()))
545                }
546            });
547            task.await
548        })
549        .detach_and_log_err(cx);
550    }
551
552    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
553        let has_external_agents = self
554            .agent_server_store
555            .upgrade()
556            .map(|store| store.read(cx).has_external_agents())
557            .unwrap_or(false);
558
559        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
560    }
561
562    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
563        let Some(agent_server_store) = self.agent_server_store.upgrade() else {
564            return;
565        };
566        let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
567            return;
568        };
569
570        let workspace_handle = self.workspace.clone();
571        let multi_workspace = self.multi_workspace.clone();
572
573        self.workspace
574            .update(cx, |workspace, cx| {
575                workspace.toggle_modal(window, cx, |window, cx| {
576                    ThreadImportModal::new(
577                        agent_server_store,
578                        agent_registry_store,
579                        workspace_handle.clone(),
580                        multi_workspace.clone(),
581                        window,
582                        cx,
583                    )
584                });
585            })
586            .log_err();
587    }
588
589    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
590        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
591        let sidebar_on_left = matches!(
592            AgentSettings::get_global(cx).sidebar_side(),
593            settings::SidebarSide::Left
594        );
595        let traffic_lights =
596            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
597        let header_height = platform_title_bar_height(window);
598        let show_focus_keybinding =
599            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
600
601        h_flex()
602            .h(header_height)
603            .mt_px()
604            .pb_px()
605            .map(|this| {
606                if traffic_lights {
607                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
608                } else {
609                    this.pl_1p5()
610                }
611            })
612            .pr_1p5()
613            .gap_1()
614            .justify_between()
615            .border_b_1()
616            .border_color(cx.theme().colors().border)
617            .when(traffic_lights, |this| {
618                this.child(Divider::vertical().color(ui::DividerColor::Border))
619            })
620            .child(
621                h_flex()
622                    .ml_1()
623                    .min_w_0()
624                    .w_full()
625                    .gap_1()
626                    .child(
627                        Icon::new(IconName::MagnifyingGlass)
628                            .size(IconSize::Small)
629                            .color(Color::Muted),
630                    )
631                    .child(self.filter_editor.clone()),
632            )
633            .when(show_focus_keybinding, |this| {
634                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
635            })
636            .when(has_query, |this| {
637                this.child(
638                    IconButton::new("clear-filter", IconName::Close)
639                        .icon_size(IconSize::Small)
640                        .tooltip(Tooltip::text("Clear Search"))
641                        .on_click(cx.listener(|this, _, window, cx| {
642                            this.reset_filter_editor_text(window, cx);
643                            this.update_items(cx);
644                        })),
645                )
646            })
647    }
648}
649
650pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
651    let now = Utc::now();
652    let duration = now.signed_duration_since(entry_time);
653
654    let minutes = duration.num_minutes();
655    let hours = duration.num_hours();
656    let days = duration.num_days();
657    let weeks = days / 7;
658    let months = days / 30;
659
660    if minutes < 60 {
661        format!("{}m", minutes.max(1))
662    } else if hours < 24 {
663        format!("{}h", hours.max(1))
664    } else if days < 7 {
665        format!("{}d", days.max(1))
666    } else if weeks < 4 {
667        format!("{}w", weeks.max(1))
668    } else {
669        format!("{}mo", months.max(1))
670    }
671}
672
673impl Focusable for ThreadsArchiveView {
674    fn focus_handle(&self, _cx: &App) -> FocusHandle {
675        self.focus_handle.clone()
676    }
677}
678
679impl Render for ThreadsArchiveView {
680    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
681        let is_empty = self.items.is_empty();
682        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
683
684        let content = if is_empty {
685            let message = if has_query {
686                "No threads match your search."
687            } else {
688                "No archived or hidden threads yet."
689            };
690
691            v_flex()
692                .flex_1()
693                .justify_center()
694                .items_center()
695                .child(
696                    Label::new(message)
697                        .size(LabelSize::Small)
698                        .color(Color::Muted),
699                )
700                .into_any_element()
701        } else {
702            v_flex()
703                .flex_1()
704                .overflow_hidden()
705                .child(
706                    list(
707                        self.list_state.clone(),
708                        cx.processor(Self::render_list_entry),
709                    )
710                    .flex_1()
711                    .size_full(),
712                )
713                .vertical_scrollbar_for(&self.list_state, window, cx)
714                .into_any_element()
715        };
716
717        v_flex()
718            .key_context("ThreadsArchiveView")
719            .track_focus(&self.focus_handle)
720            .on_action(cx.listener(Self::select_next))
721            .on_action(cx.listener(Self::select_previous))
722            .on_action(cx.listener(Self::editor_move_down))
723            .on_action(cx.listener(Self::editor_move_up))
724            .on_action(cx.listener(Self::select_first))
725            .on_action(cx.listener(Self::select_last))
726            .on_action(cx.listener(Self::confirm))
727            .on_action(cx.listener(Self::remove_selected_thread))
728            .size_full()
729            .child(self.render_header(window, cx))
730            .child(content)
731            .when(!self.should_render_acp_import_onboarding(cx), |this| {
732                this.child(
733                    div()
734                        .w_full()
735                        .p_1p5()
736                        .border_t_1()
737                        .border_color(cx.theme().colors().border)
738                        .child(
739                            Button::new("import-acp", "Import ACP Threads")
740                                .full_width()
741                                .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
742                                .label_size(LabelSize::Small)
743                                .start_icon(
744                                    Icon::new(IconName::ArrowDown)
745                                        .size(IconSize::XSmall)
746                                        .color(Color::Muted),
747                                )
748                                .on_click(cx.listener(|this, _, window, cx| {
749                                    this.show_thread_import_modal(window, cx);
750                                })),
751                        ),
752                )
753            })
754    }
755}