agent_ui: Improve onboarding to the import threads feature (#52748)

Danilo Leal and Bennet Bo Fenner created

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

crates/agent_ui/src/agent_ui.rs                |   1 
crates/agent_ui/src/thread_import.rs           | 223 ++++++++++++-------
crates/agent_ui/src/threads_archive_view.rs    | 193 ++++++++++------
crates/project/src/agent_server_store.rs       |   4 
crates/sidebar/src/sidebar.rs                  | 117 ++++++++++
crates/ui/src/components/button/button.rs      |  31 ++
crates/ui/src/components/button/button_like.rs |  35 +++
crates/ui/src/components/modal.rs              |  38 +-
8 files changed, 455 insertions(+), 187 deletions(-)

Detailed changes

crates/agent_ui/src/agent_ui.rs 🔗

@@ -79,6 +79,7 @@ pub(crate) use model_selector_popover::ModelSelectorPopover;
 pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
 pub(crate) use thread_history::ThreadHistory;
 pub(crate) use thread_history_view::*;
+pub use thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
 use zed_actions;
 
 pub const DEFAULT_THREAD_TITLE: &str = "New Thread";

crates/agent_ui/src/thread_import.rs 🔗

@@ -3,6 +3,7 @@ use agent::ThreadStore;
 use agent_client_protocol as acp;
 use chrono::Utc;
 use collections::HashSet;
+use db::kvp::Dismissable;
 use fs::Fs;
 use futures::FutureExt as _;
 use gpui::{
@@ -11,7 +12,10 @@ use gpui::{
 };
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{AgentId, AgentRegistryStore, AgentServerStore};
-use ui::{Checkbox, CommonAnimationExt as _, KeyBinding, ListItem, ListItemSpacing, prelude::*};
+use ui::{
+    Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section,
+    prelude::*,
+};
 use util::ResultExt;
 use workspace::{ModalView, MultiWorkspace, Workspace};
 
@@ -21,6 +25,22 @@ use crate::{
     thread_metadata_store::{ThreadMetadata, ThreadMetadataStore},
 };
 
+pub struct AcpThreadImportOnboarding;
+
+impl AcpThreadImportOnboarding {
+    pub fn dismissed(cx: &App) -> bool {
+        <Self as Dismissable>::dismissed(cx)
+    }
+
+    pub fn dismiss(cx: &mut App) {
+        <Self as Dismissable>::set_dismissed(true, cx);
+    }
+}
+
+impl Dismissable for AcpThreadImportOnboarding {
+    const KEY: &'static str = "dismissed-acp-thread-import";
+}
+
 #[derive(Clone)]
 struct AgentEntry {
     agent_id: AgentId,
@@ -34,6 +54,7 @@ pub struct ThreadImportModal {
     multi_workspace: WeakEntity<MultiWorkspace>,
     agent_entries: Vec<AgentEntry>,
     unchecked_agents: HashSet<AgentId>,
+    selected_index: Option<usize>,
     is_importing: bool,
     last_error: Option<SharedString>,
 }
@@ -47,6 +68,8 @@ impl ThreadImportModal {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        AcpThreadImportOnboarding::dismiss(cx);
+
         let agent_entries = agent_server_store
             .read(cx)
             .external_agents()
@@ -85,6 +108,7 @@ impl ThreadImportModal {
             multi_workspace,
             agent_entries,
             unchecked_agents: HashSet::default(),
+            selected_index: None,
             is_importing: false,
             last_error: None,
         }
@@ -118,11 +142,53 @@ impl ThreadImportModal {
         cx.notify();
     }
 
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.agent_entries.is_empty() {
+            return;
+        }
+        self.selected_index = Some(match self.selected_index {
+            Some(ix) if ix + 1 >= self.agent_entries.len() => 0,
+            Some(ix) => ix + 1,
+            None => 0,
+        });
+        cx.notify();
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.agent_entries.is_empty() {
+            return;
+        }
+        self.selected_index = Some(match self.selected_index {
+            Some(0) => self.agent_entries.len() - 1,
+            Some(ix) => ix - 1,
+            None => self.agent_entries.len() - 1,
+        });
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(ix) = self.selected_index {
+            if let Some(entry) = self.agent_entries.get(ix) {
+                self.toggle_agent_checked(entry.agent_id.clone(), cx);
+            }
+        }
+    }
+
     fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
         cx.emit(DismissEvent);
     }
 
-    fn import_threads(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
+    fn import_threads(
+        &mut self,
+        _: &menu::SecondaryConfirm,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         if self.is_importing {
             return;
         }
@@ -182,7 +248,8 @@ impl ThreadImportModal {
     fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) {
         let status_toast = if imported_count == 0 {
             StatusToast::new("No threads found to import.", cx, |this, _cx| {
-                this.icon(ToastIcon::new(IconName::Info).color(Color::Info))
+                this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
+                    .dismiss_button(true)
             })
         } else {
             let message = if imported_count == 1 {
@@ -192,6 +259,7 @@ impl ThreadImportModal {
             };
             StatusToast::new(message, cx, |this, _cx| {
                 this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+                    .dismiss_button(true)
             })
         };
 
@@ -221,11 +289,29 @@ impl Render for ThreadImportModal {
             .enumerate()
             .map(|(ix, entry)| {
                 let is_checked = !self.unchecked_agents.contains(&entry.agent_id);
+                let is_focused = self.selected_index == Some(ix);
 
                 ListItem::new(("thread-import-agent", ix))
-                    .inset(true)
+                    .rounded()
                     .spacing(ListItemSpacing::Sparse)
-                    .start_slot(
+                    .focused(is_focused)
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .when(!is_checked, |this| this.opacity(0.6))
+                            .child(if let Some(icon_path) = entry.icon_path.clone() {
+                                Icon::from_external_svg(icon_path)
+                                    .color(Color::Muted)
+                                    .size(IconSize::Small)
+                            } else {
+                                Icon::new(IconName::Sparkle)
+                                    .color(Color::Muted)
+                                    .size(IconSize::Small)
+                            })
+                            .child(Label::new(entry.display_name.clone())),
+                    )
+                    .end_slot(
                         Checkbox::new(
                             ("thread-import-agent-checkbox", ix),
                             if is_checked {
@@ -247,25 +333,13 @@ impl Render for ThreadImportModal {
                             this.toggle_agent_checked(agent_id.clone(), cx);
                         })
                     })
-                    .child(
-                        h_flex()
-                            .w_full()
-                            .gap_2()
-                            .child(if let Some(icon_path) = entry.icon_path.clone() {
-                                Icon::from_external_svg(icon_path)
-                                    .color(Color::Muted)
-                                    .size(IconSize::Small)
-                            } else {
-                                Icon::new(IconName::Sparkle)
-                                    .color(Color::Muted)
-                                    .size(IconSize::Small)
-                            })
-                            .child(Label::new(entry.display_name.clone())),
-                    )
             })
             .collect::<Vec<_>>();
 
         let has_agents = !self.agent_entries.is_empty();
+        let disabled_import_thread = self.is_importing
+            || !has_agents
+            || self.unchecked_agents.len() == self.agent_entries.len();
 
         v_flex()
             .id("thread-import-modal")
@@ -275,85 +349,62 @@ impl Render for ThreadImportModal {
             .overflow_hidden()
             .track_focus(&self.focus_handle)
             .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
             .on_action(cx.listener(Self::import_threads))
             .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
                 this.focus_handle.focus(window, cx);
             }))
-            // Header
-            .child(
-                v_flex()
-                    .p_4()
-                    .pb_2()
-                    .gap_1()
-                    .child(Headline::new("Import Threads").size(HeadlineSize::Small))
-                    .child(
-                        Label::new(
-                            "Select the agents whose threads you'd like to import. \
-                             Imported threads will appear in your thread archive.",
-                        )
-                        .color(Color::Muted)
-                        .size(LabelSize::Small),
-                    ),
-            )
-            // Agent list
-            .child(
-                v_flex()
-                    .id("thread-import-agent-list")
-                    .px_2()
-                    .max_h(rems(20.))
-                    .overflow_y_scroll()
-                    .when(has_agents, |this| this.children(agent_rows))
-                    .when(!has_agents, |this| {
-                        this.child(
-                            div().p_4().child(
-                                Label::new("No ACP agents available.")
-                                    .color(Color::Muted)
-                                    .size(LabelSize::Small),
-                            ),
-                        )
-                    }),
-            )
-            // Footer
             .child(
-                h_flex()
-                    .w_full()
-                    .p_3()
-                    .gap_2()
-                    .items_center()
-                    .border_t_1()
-                    .border_color(cx.theme().colors().border_variant)
-                    .child(div().flex_1().min_w_0().when_some(
-                        self.last_error.clone(),
-                        |this, error| {
-                            this.child(
-                                Label::new(error)
-                                    .size(LabelSize::Small)
-                                    .color(Color::Error)
-                                    .truncate(),
+                Modal::new("import-threads", None)
+                    .header(
+                        ModalHeader::new()
+                            .headline("Import ACP Threads")
+                            .description(
+                                "Import threads from your ACP agents — whether started in Zed or another client. \
+                                Choose which agents to include, and their threads will appear in your archive."
                             )
-                        },
-                    ))
-                    .child(
-                        h_flex()
-                            .gap_2()
-                            .items_center()
-                            .when(self.is_importing, |this| {
-                                this.child(
-                                    Icon::new(IconName::ArrowCircle)
-                                        .size(IconSize::Small)
-                                        .color(Color::Muted)
-                                        .with_rotate_animation(2),
+                            .show_dismiss_button(true),
+
+                    )
+                    .section(
+                        Section::new().child(
+                            v_flex()
+                                .id("thread-import-agent-list")
+                                .max_h(rems_from_px(320.))
+                                .pb_2()
+                                .overflow_y_scroll()
+                                .when(has_agents, |this| this.children(agent_rows))
+                                .when(!has_agents, |this| {
+                                    this.child(
+                                        Label::new("No ACP agents available.")
+                                            .color(Color::Muted)
+                                            .size(LabelSize::Small),
+                                    )
+                                }),
+                        ),
+                    )
+                    .footer(
+                        ModalFooter::new()
+                            .when_some(self.last_error.clone(), |this, error| {
+                                this.start_slot(
+                                    Label::new(error)
+                                        .size(LabelSize::Small)
+                                        .color(Color::Error)
+                                        .truncate(),
                                 )
                             })
-                            .child(
+                            .end_slot(
                                 Button::new("import-threads", "Import Threads")
-                                    .disabled(self.is_importing || !has_agents)
+                                    .loading(self.is_importing)
+                                    .disabled(disabled_import_thread)
                                     .key_binding(
-                                        KeyBinding::for_action(&menu::Confirm, cx)
+                                        KeyBinding::for_action(&menu::SecondaryConfirm, cx)
                                             .map(|kb| kb.size(rems_from_px(12.))),
                                     )
                                     .on_click(cx.listener(|this, _, window, cx| {
-                                        this.import_threads(&menu::Confirm, window, cx);
+                                        this.import_threads(&menu::SecondaryConfirm, window, cx);
                                     })),
                             ),
                     ),

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -1,5 +1,5 @@
 use crate::agent_connection_store::AgentConnectionStore;
-use crate::thread_import::ThreadImportModal;
+use crate::thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
 use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
 use crate::{Agent, RemoveSelectedThread};
 
@@ -108,6 +108,7 @@ pub struct ThreadsArchiveView {
     items: Vec<ArchiveListItem>,
     selection: Option<usize>,
     hovered_index: Option<usize>,
+    preserve_selection_on_next_update: bool,
     filter_editor: Entity<Editor>,
     _subscriptions: Vec<gpui::Subscription>,
     _refresh_history_task: Task<()>,
@@ -176,6 +177,7 @@ impl ThreadsArchiveView {
             items: Vec::new(),
             selection: None,
             hovered_index: None,
+            preserve_selection_on_next_update: false,
             filter_editor,
             _subscriptions: vec![
                 filter_editor_subscription,
@@ -251,10 +253,36 @@ impl ThreadsArchiveView {
             });
         }
 
+        let preserve = self.preserve_selection_on_next_update;
+        self.preserve_selection_on_next_update = false;
+
+        let saved_scroll = if preserve {
+            Some(self.list_state.logical_scroll_top())
+        } else {
+            None
+        };
+
         self.list_state.reset(items.len());
         self.items = items;
-        self.selection = None;
         self.hovered_index = None;
+
+        if let Some(scroll_top) = saved_scroll {
+            self.list_state.scroll_to(scroll_top);
+
+            if let Some(ix) = self.selection {
+                let next = self.find_next_selectable(ix).or_else(|| {
+                    ix.checked_sub(1)
+                        .and_then(|i| self.find_previous_selectable(i))
+                });
+                self.selection = next;
+                if let Some(next) = next {
+                    self.list_state.scroll_to_reveal_item(next);
+                }
+            }
+        } else {
+            self.selection = None;
+        }
+
         cx.notify();
     }
 
@@ -435,66 +463,56 @@ impl ThreadsArchiveView {
                         cx.notify();
                     }))
                     .action_slot(
-                        h_flex()
-                            .gap_2()
-                            .when(is_hovered || is_focused, |this| {
-                                let focus_handle = self.focus_handle.clone();
-                                this.child(
-                                    Button::new("unarchive-thread", "Open")
-                                        .style(ButtonStyle::Filled)
-                                        .label_size(LabelSize::Small)
-                                        .when(is_focused, |this| {
-                                            this.key_binding(
-                                                KeyBinding::for_action_in(
-                                                    &menu::Confirm,
-                                                    &focus_handle,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(12.))),
-                                            )
-                                        })
-                                        .on_click({
-                                            let thread = thread.clone();
-                                            cx.listener(move |this, _, window, cx| {
-                                                this.unarchive_thread(thread.clone(), window, cx);
-                                            })
-                                        }),
-                                )
+                        IconButton::new("delete-thread", IconName::Trash)
+                            .style(ButtonStyle::Filled)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip({
+                                move |_window, cx| {
+                                    Tooltip::for_action_in(
+                                        "Delete Thread",
+                                        &RemoveSelectedThread,
+                                        &focus_handle,
+                                        cx,
+                                    )
+                                }
                             })
-                            .child(
-                                IconButton::new("delete-thread", IconName::Trash)
-                                    .style(ButtonStyle::Filled)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(Color::Muted)
-                                    .tooltip({
-                                        move |_window, cx| {
-                                            Tooltip::for_action_in(
-                                                "Delete Thread",
-                                                &RemoveSelectedThread,
-                                                &focus_handle,
-                                                cx,
-                                            )
-                                        }
-                                    })
-                                    .on_click({
-                                        let agent = thread.agent_id.clone();
-                                        let session_id = thread.session_id.clone();
-                                        cx.listener(move |this, _, _, cx| {
-                                            this.delete_thread(
-                                                session_id.clone(),
-                                                agent.clone(),
-                                                cx,
-                                            );
-                                            cx.stop_propagation();
-                                        })
-                                    }),
-                            ),
+                            .on_click({
+                                let agent = thread.agent_id.clone();
+                                let session_id = thread.session_id.clone();
+                                cx.listener(move |this, _, _, cx| {
+                                    this.delete_thread(session_id.clone(), agent.clone(), cx);
+                                    cx.stop_propagation();
+                                })
+                            }),
                     )
+                    .tooltip(move |_, cx| Tooltip::for_action("Restore Thread", &menu::Confirm, cx))
+                    .on_click({
+                        let thread = thread.clone();
+                        cx.listener(move |this, _, window, cx| {
+                            this.unarchive_thread(thread.clone(), window, cx);
+                        })
+                    })
                     .into_any_element()
             }
         }
     }
 
+    fn remove_selected_thread(
+        &mut self,
+        _: &RemoveSelectedThread,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(ix) = self.selection else { return };
+        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
+            return;
+        };
+
+        self.preserve_selection_on_next_update = true;
+        self.delete_thread(thread.session_id.clone(), thread.agent_id.clone(), cx);
+    }
+
     fn delete_thread(
         &mut self,
         session_id: acp::SessionId,
@@ -531,6 +549,16 @@ impl ThreadsArchiveView {
         .detach_and_log_err(cx);
     }
 
+    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
+        let has_external_agents = self
+            .agent_server_store
+            .upgrade()
+            .map(|store| store.read(cx).has_external_agents())
+            .unwrap_or(false);
+
+        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
+    }
+
     fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(agent_server_store) = self.agent_server_store.upgrade() else {
             return;
@@ -605,27 +633,16 @@ impl ThreadsArchiveView {
             .when(show_focus_keybinding, |this| {
                 this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
             })
-            .map(|this| {
-                if has_query {
-                    this.child(
-                        IconButton::new("clear-filter", IconName::Close)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("Clear Search"))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.reset_filter_editor_text(window, cx);
-                                this.update_items(cx);
-                            })),
-                    )
-                } else {
-                    this.child(
-                        IconButton::new("import-thread", IconName::Plus)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("Import ACP Threads"))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.show_thread_import_modal(window, cx);
-                            })),
-                    )
-                }
+            .when(has_query, |this| {
+                this.child(
+                    IconButton::new("clear-filter", IconName::Close)
+                        .icon_size(IconSize::Small)
+                        .tooltip(Tooltip::text("Clear Search"))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.reset_filter_editor_text(window, cx);
+                            this.update_items(cx);
+                        })),
+                )
             })
     }
 }
@@ -707,8 +724,32 @@ impl Render for ThreadsArchiveView {
             .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_last))
             .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::remove_selected_thread))
             .size_full()
             .child(self.render_header(window, cx))
             .child(content)
+            .when(!self.should_render_acp_import_onboarding(cx), |this| {
+                this.child(
+                    div()
+                        .w_full()
+                        .p_1p5()
+                        .border_t_1()
+                        .border_color(cx.theme().colors().border)
+                        .child(
+                            Button::new("import-acp", "Import ACP Threads")
+                                .full_width()
+                                .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
+                                .label_size(LabelSize::Small)
+                                .start_icon(
+                                    Icon::new(IconName::ArrowDown)
+                                        .size(IconSize::XSmall)
+                                        .color(Color::Muted),
+                                )
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.show_thread_import_modal(window, cx);
+                                })),
+                        ),
+                )
+            })
     }
 }

crates/project/src/agent_server_store.rs 🔗

@@ -695,6 +695,10 @@ impl AgentServerStore {
         }
     }
 
+    pub fn has_external_agents(&self) -> bool {
+        !self.external_agents.is_empty()
+    }
+
     pub fn external_agents(&self) -> impl Iterator<Item = &AgentId> {
         self.external_agents.keys()
     }

crates/sidebar/src/sidebar.rs 🔗

@@ -8,6 +8,7 @@ use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
 use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
 };
+use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal};
 use agent_ui::{
     Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
 };
@@ -16,7 +17,8 @@ use editor::Editor;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
 use gpui::{
     Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
-    Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
+    Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop,
+    linear_gradient, list, prelude::*, px,
 };
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
@@ -3134,9 +3136,9 @@ impl Sidebar {
                 h_flex()
                     .w_1_2()
                     .gap_2()
-                    .child(Divider::horizontal())
+                    .child(Divider::horizontal().color(ui::DividerColor::Border))
                     .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
-                    .child(Divider::horizontal()),
+                    .child(Divider::horizontal().color(ui::DividerColor::Border)),
             )
             .child(
                 Button::new("clone_repo", "Clone Repository")
@@ -3325,6 +3327,112 @@ impl Sidebar {
         }
     }
 
+    fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
+        self.multi_workspace.upgrade().and_then(|w| {
+            w.read(cx)
+                .workspaces()
+                .get(w.read(cx).active_workspace_index())
+                .cloned()
+        })
+    }
+
+    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(active_workspace) = self.active_workspace(cx) else {
+            return;
+        };
+
+        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
+            return;
+        };
+
+        let agent_server_store = active_workspace
+            .read(cx)
+            .project()
+            .read(cx)
+            .agent_server_store()
+            .clone();
+
+        let workspace_handle = active_workspace.downgrade();
+        let multi_workspace = self.multi_workspace.clone();
+
+        active_workspace.update(cx, |workspace, cx| {
+            workspace.toggle_modal(window, cx, |window, cx| {
+                ThreadImportModal::new(
+                    agent_server_store,
+                    agent_registry_store,
+                    workspace_handle.clone(),
+                    multi_workspace.clone(),
+                    window,
+                    cx,
+                )
+            });
+        });
+    }
+
+    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
+        let has_external_agents = self
+            .active_workspace(cx)
+            .map(|ws| {
+                ws.read(cx)
+                    .project()
+                    .read(cx)
+                    .agent_server_store()
+                    .read(cx)
+                    .has_external_agents()
+            })
+            .unwrap_or(false);
+
+        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
+    }
+
+    fn render_acp_import_onboarding(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let description =
+            "Import threads from your ACP agents — whether started in Zed or another client.";
+
+        let bg = cx.theme().colors().text_accent;
+
+        v_flex()
+            .min_w_0()
+            .w_full()
+            .p_1p5()
+            .border_t_1()
+            .border_color(cx.theme().colors().border)
+            .bg(linear_gradient(
+                360.,
+                linear_color_stop(bg.opacity(0.06), 1.),
+                linear_color_stop(bg.opacity(0.), 0.),
+            ))
+            .child(
+                h_flex()
+                    .min_w_0()
+                    .w_full()
+                    .gap_1()
+                    .justify_between()
+                    .child(Label::new("Looking for ACP threads?"))
+                    .child(
+                        IconButton::new("close-onboarding", IconName::Close)
+                            .icon_size(IconSize::Small)
+                            .on_click(|_, _window, cx| AcpThreadImportOnboarding::dismiss(cx)),
+                    ),
+            )
+            .child(Label::new(description).color(Color::Muted).mb_2())
+            .child(
+                Button::new("import-acp", "Import ACP Threads")
+                    .full_width()
+                    .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
+                    .label_size(LabelSize::Small)
+                    .start_icon(
+                        Icon::new(IconName::ArrowDown)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.show_archive(window, cx);
+                        this.show_thread_import_modal(window, cx);
+                    })),
+            )
+    }
+
     fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
         match &self.view {
             SidebarView::ThreadList => self.show_archive(window, cx),
@@ -3569,6 +3677,9 @@ impl Render for Sidebar {
                     }),
                 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
             })
+            .when(self.should_render_acp_import_onboarding(cx), |this| {
+                this.child(self.render_acp_import_onboarding(cx))
+            })
             .child(self.render_sidebar_bottom_bar(cx))
     }
 }

crates/ui/src/components/button/button.rs 🔗

@@ -2,6 +2,7 @@ use crate::component_prelude::*;
 use gpui::{AnyElement, AnyView, DefiniteLength};
 use ui_macros::RegisterComponent;
 
+use crate::traits::animation_ext::CommonAnimationExt;
 use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label};
 use crate::{
     Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*,
@@ -88,6 +89,7 @@ pub struct Button {
     key_binding_position: KeybindingPosition,
     alpha: Option<f32>,
     truncate: bool,
+    loading: bool,
 }
 
 impl Button {
@@ -111,6 +113,7 @@ impl Button {
             key_binding_position: KeybindingPosition::default(),
             alpha: None,
             truncate: false,
+            loading: false,
         }
     }
 
@@ -183,6 +186,14 @@ impl Button {
         self.truncate = truncate;
         self
     }
+
+    /// Displays a rotating loading spinner in place of the `start_icon`.
+    ///
+    /// When `loading` is `true`, any `start_icon` is ignored. and a rotating
+    pub fn loading(mut self, loading: bool) -> Self {
+        self.loading = loading;
+        self
+    }
 }
 
 impl Toggleable for Button {
@@ -378,11 +389,21 @@ impl RenderOnce for Button {
             h_flex()
                 .when(self.truncate, |this| this.min_w_0().overflow_hidden())
                 .gap(DynamicSpacing::Base04.rems(cx))
-                .when_some(self.start_icon, |this, icon| {
-                    this.child(if is_disabled {
-                        icon.color(Color::Disabled)
-                    } else {
-                        icon
+                .when(self.loading, |this| {
+                    this.child(
+                        Icon::new(IconName::LoadCircle)
+                            .size(IconSize::Small)
+                            .color(Color::Muted)
+                            .with_rotate_animation(2),
+                    )
+                })
+                .when(!self.loading, |this| {
+                    this.when_some(self.start_icon, |this, icon| {
+                        this.child(if is_disabled {
+                            icon.color(Color::Disabled)
+                        } else {
+                            icon
+                        })
                     })
                 })
                 .child(

crates/ui/src/components/button/button_like.rs 🔗

@@ -138,6 +138,9 @@ pub enum ButtonStyle {
     /// A more de-emphasized version of the outlined button.
     OutlinedGhost,
 
+    /// Like [`ButtonStyle::Outlined`], but with a caller-provided border color.
+    OutlinedCustom(Hsla),
+
     /// The default button style, used for most buttons. Has a transparent background,
     /// but has a background color to indicate states like hover and active.
     #[default]
@@ -230,6 +233,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
+                background: transparent_black(),
+                border_color,
+                label_color: Color::Default.color(cx),
+                icon_color: Color::Default.color(cx),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_background,
                 border_color: transparent_black(),
@@ -280,6 +289,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
+                background: cx.theme().colors().ghost_element_hover,
+                border_color,
+                label_color: Color::Default.color(cx),
+                icon_color: Color::Default.color(cx),
+            },
             ButtonStyle::Subtle => ButtonLikeStyles {
                 background: cx.theme().colors().ghost_element_hover,
                 border_color: transparent_black(),
@@ -324,6 +339,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
+                background: cx.theme().colors().element_active,
+                border_color,
+                label_color: Color::Default.color(cx),
+                icon_color: Color::Default.color(cx),
+            },
             ButtonStyle::Transparent => ButtonLikeStyles {
                 background: transparent_black(),
                 border_color: transparent_black(),
@@ -363,6 +384,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
+                background: cx.theme().colors().ghost_element_background,
+                border_color,
+                label_color: Color::Default.color(cx),
+                icon_color: Color::Default.color(cx),
+            },
             ButtonStyle::Transparent => ButtonLikeStyles {
                 background: transparent_black(),
                 border_color: cx.theme().colors().border_focused,
@@ -405,6 +432,12 @@ impl ButtonStyle {
                 label_color: Color::Default.color(cx),
                 icon_color: Color::Default.color(cx),
             },
+            ButtonStyle::OutlinedCustom(_) => ButtonLikeStyles {
+                background: cx.theme().colors().element_disabled,
+                border_color: cx.theme().colors().border_disabled,
+                label_color: Color::Default.color(cx),
+                icon_color: Color::Default.color(cx),
+            },
             ButtonStyle::Transparent => ButtonLikeStyles {
                 background: transparent_black(),
                 border_color: transparent_black(),
@@ -640,7 +673,7 @@ impl RenderOnce for ButtonLike {
 
         let is_outlined = matches!(
             self.style,
-            ButtonStyle::Outlined | ButtonStyle::OutlinedGhost
+            ButtonStyle::Outlined | ButtonStyle::OutlinedGhost | ButtonStyle::OutlinedCustom(_)
         );
 
         self.base

crates/ui/src/components/modal.rs 🔗

@@ -1,7 +1,5 @@
-use crate::{
-    Clickable, Color, DynamicSpacing, Headline, HeadlineSize, Icon, IconButton, IconButtonShape,
-    IconName, Label, LabelCommon, LabelSize, h_flex, v_flex,
-};
+use crate::{IconButtonShape, prelude::*};
+
 use gpui::{prelude::FluentBuilder, *};
 use smallvec::SmallVec;
 use theme::ActiveTheme;
@@ -169,6 +167,7 @@ impl RenderOnce for ModalHeader {
         }
 
         h_flex()
+            .min_w_0()
             .flex_none()
             .justify_between()
             .w_full()
@@ -187,26 +186,33 @@ impl RenderOnce for ModalHeader {
             })
             .child(
                 v_flex()
+                    .min_w_0()
                     .flex_1()
                     .child(
                         h_flex()
+                            .w_full()
                             .gap_1()
-                            .when_some(self.icon, |this, icon| this.child(icon))
-                            .children(children),
+                            .justify_between()
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .when_some(self.icon, |this, icon| this.child(icon))
+                                    .children(children),
+                            )
+                            .when(self.show_dismiss_button, |this| {
+                                this.child(
+                                    IconButton::new("dismiss", IconName::Close)
+                                        .icon_size(IconSize::Small)
+                                        .on_click(|_, window, cx| {
+                                            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
+                                        }),
+                                )
+                            }),
                     )
                     .when_some(self.description, |this, description| {
-                        this.child(Label::new(description).color(Color::Muted).mb_2())
+                        this.child(Label::new(description).color(Color::Muted).mb_2().flex_1())
                     }),
             )
-            .when(self.show_dismiss_button, |this| {
-                this.child(
-                    IconButton::new("dismiss", IconName::Close)
-                        .shape(IconButtonShape::Square)
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
-                        }),
-                )
-            })
     }
 }