Simplify parallel agents onboarding (#53854)

Danilo Leal created

- Adds a status toast to the announcement banner for surfacing the
layout revert option
- Removes the agent panel banner

A good chunk of the diff here was because I touched up the status toast
component API a little bit.

Release Notes:

- N/A

Change summary

Cargo.lock                                                                |   1 
crates/agent_ui/src/agent_configuration.rs                                |  68 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs |  10 
crates/agent_ui/src/agent_panel.rs                                        | 101 
crates/agent_ui/src/thread_import.rs                                      |  22 
crates/agent_ui/src/ui/undo_reject_toast.rs                               |  24 
crates/ai_onboarding/src/ai_onboarding.rs                                 | 132 
crates/auto_update_ui/Cargo.toml                                          |   1 
crates/auto_update_ui/src/auto_update_ui.rs                               |  41 
crates/component_preview/src/component_preview.rs                         |  14 
crates/debugger_ui/src/session/running/memory_view.rs                     |   4 
crates/git_ui/src/clone.rs                                                |  12 
crates/git_ui/src/git_panel.rs                                            |  44 
crates/keymap_editor/src/keymap_editor.rs                                 |  10 
crates/notifications/src/status_toast.rs                                  |  90 
crates/onboarding/src/onboarding.rs                                       |  24 
crates/project_panel/src/project_panel.rs                                 |  10 
crates/ui/src/components/ai/parallel_agents_illustration.rs               | 199 
crates/ui/src/components/icon.rs                                          |   3 
crates/workspace/src/toast_layer.rs                                       |   9 
20 files changed, 409 insertions(+), 410 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1251,6 +1251,7 @@ dependencies = [
  "fs",
  "gpui",
  "markdown_preview",
+ "notifications",
  "release_channel",
  "semver",
  "serde",

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -26,7 +26,7 @@ use language_model::{
     ZED_CLOUD_PROVIDER_ID,
 };
 use language_models::AllLanguageModelSettings;
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use project::{
     agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
     context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
@@ -1330,40 +1330,44 @@ fn show_unable_to_uninstall_extension_with_context_server(
         move |this, _cx| {
             let workspace_handle = workspace_handle.clone();
 
-            this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
-                .dismiss_button(true)
-                .action("Uninstall", move |_, _cx| {
-                    if let Some((extension_id, _)) =
-                        resolve_extension_for_context_server(&context_server_id, _cx)
-                    {
-                        ExtensionStore::global(_cx).update(_cx, |store, cx| {
-                            store
-                                .uninstall_extension(extension_id, cx)
-                                .detach_and_log_err(cx);
-                        });
+            this.icon(
+                Icon::new(IconName::Warning)
+                    .size(IconSize::Small)
+                    .color(Color::Warning),
+            )
+            .dismiss_button(true)
+            .action("Uninstall", move |_, _cx| {
+                if let Some((extension_id, _)) =
+                    resolve_extension_for_context_server(&context_server_id, _cx)
+                {
+                    ExtensionStore::global(_cx).update(_cx, |store, cx| {
+                        store
+                            .uninstall_extension(extension_id, cx)
+                            .detach_and_log_err(cx);
+                    });
 
-                        workspace_handle
-                            .update(_cx, |workspace, cx| {
-                                let fs = workspace.app_state().fs.clone();
-                                cx.spawn({
-                                    let context_server_id = context_server_id.clone();
-                                    async move |_workspace_handle, cx| {
-                                        cx.update(|cx| {
-                                            update_settings_file(fs, cx, move |settings, _| {
-                                                settings
-                                                    .project
-                                                    .context_servers
-                                                    .remove(&context_server_id.0);
-                                            });
+                    workspace_handle
+                        .update(_cx, |workspace, cx| {
+                            let fs = workspace.app_state().fs.clone();
+                            cx.spawn({
+                                let context_server_id = context_server_id.clone();
+                                async move |_workspace_handle, cx| {
+                                    cx.update(|cx| {
+                                        update_settings_file(fs, cx, move |settings, _| {
+                                            settings
+                                                .project
+                                                .context_servers
+                                                .remove(&context_server_id.0);
                                         });
-                                        anyhow::Ok(())
-                                    }
-                                })
-                                .detach_and_log_err(cx);
+                                    });
+                                    anyhow::Ok(())
+                                }
                             })
-                            .log_err();
-                    }
-                })
+                            .detach_and_log_err(cx);
+                        })
+                        .log_err();
+                }
+            })
         },
     );
 

crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs 🔗

@@ -9,7 +9,7 @@ use gpui::{
 };
 use language::{Language, LanguageRegistry};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use parking_lot::Mutex;
 use project::{
     context_server_store::{
@@ -631,8 +631,12 @@ impl ConfigureContextServerModal {
                         format!("{} configured successfully.", id.0),
                         cx,
                         |this, _cx| {
-                            this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
-                                .action("Dismiss", |_, _| {})
+                            this.icon(
+                                Icon::new(IconName::ToolHammer)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .action("Dismiss", |_, _| {})
                         },
                     );
 

crates/agent_ui/src/agent_panel.rs 🔗

@@ -49,7 +49,7 @@ use crate::{
 use crate::{ExpandMessageEditor, ThreadHistoryView};
 use crate::{ManageProfiles, ThreadHistoryViewEvent};
 use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
-use agent_settings::{AgentSettings, WindowLayout};
+use agent_settings::AgentSettings;
 use ai_onboarding::AgentPanelOnboarding;
 use anyhow::{Context as _, Result, anyhow};
 use client::UserStore;
@@ -760,8 +760,6 @@ pub struct AgentPanel {
     pending_serialization: Option<Task<Result<()>>>,
     new_user_onboarding: Entity<AgentPanelOnboarding>,
     new_user_onboarding_upsell_dismissed: AtomicBool,
-    agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
-    agent_layout_onboarding_dismissed: AtomicBool,
     selected_agent: Agent,
     pending_thread_loads: usize,
     worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
@@ -1065,46 +1063,6 @@ impl AgentPanel {
             )
         });
 
-        let weak_panel = cx.entity().downgrade();
-
-        let layout = AgentSettings::get_layout(cx);
-        let is_agent_layout = matches!(layout, WindowLayout::Agent(_));
-
-        let agent_layout_onboarding = cx.new(|_cx| ai_onboarding::AgentLayoutOnboarding {
-            use_agent_layout: Arc::new({
-                let fs = fs.clone();
-                let weak_panel = weak_panel.clone();
-                move |_window, cx| {
-                    let _ = AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
-                    weak_panel
-                        .update(cx, |panel, cx| {
-                            panel.dismiss_agent_layout_onboarding(cx);
-                        })
-                        .ok();
-                }
-            }),
-            revert_to_editor_layout: Arc::new({
-                let fs = fs.clone();
-                let weak_panel = weak_panel.clone();
-                move |_window, cx| {
-                    let _ = AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
-                    weak_panel
-                        .update(cx, |panel, cx| {
-                            panel.dismiss_agent_layout_onboarding(cx);
-                        })
-                        .ok();
-                }
-            }),
-            dismissed: Arc::new(move |_window, cx| {
-                weak_panel
-                    .update(cx, |panel, cx| {
-                        panel.dismiss_agent_layout_onboarding(cx);
-                    })
-                    .ok();
-            }),
-            is_agent_layout,
-        });
-
         // Subscribe to extension events to sync agent servers when extensions change
         let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
         {
@@ -1169,7 +1127,6 @@ impl AgentPanel {
             zoomed: false,
             pending_serialization: None,
             new_user_onboarding: onboarding,
-            agent_layout_onboarding,
             thread_store,
             selected_agent: Agent::default(),
             pending_thread_loads: 0,
@@ -1179,9 +1136,6 @@ impl AgentPanel {
             _worktree_creation_task: None,
             show_trust_workspace_message: false,
             new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
-            agent_layout_onboarding_dismissed: AtomicBool::new(AgentLayoutOnboarding::dismissed(
-                cx,
-            )),
             _base_view_observation: None,
             _draft_editor_observation: None,
         };
@@ -4676,56 +4630,10 @@ impl AgentPanel {
         plan.is_some_and(|plan| plan == Plan::ZedFree) && has_previous_trial
     }
 
-    fn should_render_agent_layout_onboarding(&self, cx: &mut Context<Self>) -> bool {
-        // We only want to show this for existing users: those who
-        // have used the agent panel before the sidebar was introduced.
-        // We can infer that state by users having seen the onboarding
-        // at one point, but not the agent layout onboarding.
-
-        let has_messages = self.active_thread_has_messages(cx);
-        let is_dismissed = self
-            .agent_layout_onboarding_dismissed
-            .load(Ordering::Acquire);
-
-        if is_dismissed || has_messages {
-            return false;
-        }
-
-        match &self.base_view {
-            BaseView::Uninitialized => false,
-            BaseView::AgentThread { .. } => {
-                let existing_user = self
-                    .new_user_onboarding_upsell_dismissed
-                    .load(Ordering::Acquire);
-                existing_user
-            }
-        }
-    }
-
-    fn render_agent_layout_onboarding(
-        &self,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<impl IntoElement> {
-        if !self.should_render_agent_layout_onboarding(cx) {
-            return None;
-        }
-
-        Some(div().child(self.agent_layout_onboarding.clone()))
-    }
-
-    fn dismiss_agent_layout_onboarding(&mut self, cx: &mut Context<Self>) {
-        self.agent_layout_onboarding_dismissed
-            .store(true, Ordering::Release);
-        AgentLayoutOnboarding::set_dismissed(true, cx);
-        cx.notify();
-    }
-
     fn dismiss_ai_onboarding(&mut self, cx: &mut Context<Self>) {
         self.new_user_onboarding_upsell_dismissed
             .store(true, Ordering::Release);
         OnboardingUpsell::set_dismissed(true, cx);
-        self.dismiss_agent_layout_onboarding(cx);
         cx.notify();
     }
 
@@ -4987,7 +4895,6 @@ impl Render for AgentPanel {
             .child(self.render_toolbar(window, cx))
             .children(self.render_workspace_trust_message(cx))
             .children(self.render_new_user_onboarding(window, cx))
-            .children(self.render_agent_layout_onboarding(window, cx))
             .map(|parent| match self.visible_surface() {
                 VisibleSurface::Uninitialized => parent,
                 VisibleSurface::AgentThread(conversation_view) => parent
@@ -5078,12 +4985,6 @@ impl Dismissable for OnboardingUpsell {
     const KEY: &'static str = "dismissed-trial-upsell";
 }
 
-struct AgentLayoutOnboarding;
-
-impl Dismissable for AgentLayoutOnboarding {
-    const KEY: &'static str = "dismissed-agent-layout-onboarding";
-}
-
 struct TrialEndUpsell;
 
 impl Dismissable for TrialEndUpsell {

crates/agent_ui/src/thread_import.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
     App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
     Render, SharedString, Task, WeakEntity, Window,
 };
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use project::{AgentId, AgentRegistryStore, AgentServerStore};
 use release_channel::ReleaseChannel;
 use remote::RemoteConnectionOptions;
@@ -275,8 +275,12 @@ 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::Muted))
-                    .dismiss_button(true)
+                this.icon(
+                    Icon::new(IconName::Info)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .dismiss_button(true)
             })
         } else {
             let message = if imported_count == 1 {
@@ -285,8 +289,12 @@ impl ThreadImportModal {
                 format!("Imported {imported_count} threads.")
             };
             StatusToast::new(message, cx, |this, _cx| {
-                this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
-                    .dismiss_button(true)
+                this.icon(
+                    Icon::new(IconName::Check)
+                        .size(IconSize::Small)
+                        .color(Color::Success),
+                )
+                .dismiss_button(true)
             })
         };
 
@@ -660,7 +668,7 @@ fn show_cross_channel_import_toast(
 ) {
     let status_toast = if imported_count == 0 {
         StatusToast::new("No new threads found to import.", cx, |this, _cx| {
-            this.icon(ToastIcon::new(IconName::Info).color(Color::Muted))
+            this.icon(Icon::new(IconName::Info).color(Color::Muted))
                 .dismiss_button(true)
         })
     } else {
@@ -670,7 +678,7 @@ fn show_cross_channel_import_toast(
             format!("Imported {imported_count} threads from other channels.")
         };
         StatusToast::new(message, cx, |this, _cx| {
-            this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+            this.icon(Icon::new(IconName::Check).color(Color::Success))
                 .dismiss_button(true)
         })
     };

crates/agent_ui/src/ui/undo_reject_toast.rs 🔗

@@ -1,6 +1,6 @@
 use action_log::ActionLog;
 use gpui::{App, Entity};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use ui::prelude::*;
 use workspace::Workspace;
 
@@ -11,15 +11,19 @@ pub fn show_undo_reject_toast(
 ) {
     let action_log_weak = action_log.downgrade();
     let status_toast = StatusToast::new("Agent Changes Rejected", cx, move |this, _cx| {
-        this.icon(ToastIcon::new(IconName::Undo).color(Color::Muted))
-            .action("Undo", move |_window, cx| {
-                if let Some(action_log) = action_log_weak.upgrade() {
-                    action_log
-                        .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
-                        .detach();
-                }
-            })
-            .dismiss_button(true)
+        this.icon(
+            Icon::new(IconName::Undo)
+                .size(IconSize::Small)
+                .color(Color::Muted),
+        )
+        .action("Undo", move |_window, cx| {
+            if let Some(action_log) = action_log_weak.upgrade() {
+                action_log
+                    .update(cx, |action_log, cx| action_log.undo_last_reject(cx))
+                    .detach();
+            }
+        })
+        .dismiss_button(true)
     });
     workspace.toggle_status_toast(status_toast, cx);
 }

crates/ai_onboarding/src/ai_onboarding.rs 🔗

@@ -17,9 +17,7 @@ use std::sync::Arc;
 
 use client::{Client, UserStore, zed_urls};
 use gpui::{AnyElement, Entity, IntoElement, ParentElement};
-use ui::{
-    Divider, List, ListBulletItem, RegisterComponent, Tooltip, Vector, VectorName, prelude::*,
-};
+use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*};
 
 #[derive(PartialEq)]
 pub enum SignInStatus {
@@ -442,131 +440,3 @@ impl Component for ZedAiOnboarding {
         )
     }
 }
-
-#[derive(RegisterComponent)]
-pub struct AgentLayoutOnboarding {
-    pub use_agent_layout: Arc<dyn Fn(&mut Window, &mut App)>,
-    pub revert_to_editor_layout: Arc<dyn Fn(&mut Window, &mut App)>,
-    pub dismissed: Arc<dyn Fn(&mut Window, &mut App)>,
-    pub is_agent_layout: bool,
-}
-
-impl Render for AgentLayoutOnboarding {
-    fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        let description = "With the new Threads Sidebar, you can manage multiple agents across several projects, all in one window.";
-
-        let dismiss_button = div().absolute().top_0().right_0().child(
-            IconButton::new("dismiss", IconName::Close)
-                .icon_size(IconSize::Small)
-                .on_click({
-                    let dismiss = self.dismissed.clone();
-                    move |_, window, cx| {
-                        telemetry::event!("Agentic Layout Onboarding Dismissed");
-                        dismiss(window, cx)
-                    }
-                }),
-        );
-
-        let primary_button = if self.is_agent_layout {
-            Button::new("revert", "Use Previous Layout")
-                .label_size(LabelSize::Small)
-                .style(ButtonStyle::Outlined)
-                .on_click({
-                    let revert = self.revert_to_editor_layout.clone();
-                    let dismiss = self.dismissed.clone();
-                    move |_, window, cx| {
-                        telemetry::event!("Clicked to Use Previous Layout");
-                        revert(window, cx);
-                        dismiss(window, cx);
-                    }
-                })
-        } else {
-            Button::new("start", "Use New Layout")
-                .label_size(LabelSize::Small)
-                .style(ButtonStyle::Outlined)
-                .on_click({
-                    let use_layout = self.use_agent_layout.clone();
-                    let dismiss = self.dismissed.clone();
-                    move |_, window, cx| {
-                        telemetry::event!("Clicked to Use New Layout");
-                        use_layout(window, cx);
-                        dismiss(window, cx);
-                    }
-                })
-        };
-
-        let content = v_flex()
-            .min_w_0()
-            .w_full()
-            .relative()
-            .gap_1()
-            .child(Label::new("A new workspace layout for agentic workflows"))
-            .child(Label::new(description).color(Color::Muted).mb_2())
-            .child(
-                List::new()
-                    .child(ListBulletItem::new(
-                        "The Sidebar and Agent Panel are on the left by default",
-                    ))
-                    .child(ListBulletItem::new(
-                        "The Project Panel and all other panels shift to the right",
-                    ))
-                    .child(ListBulletItem::new(
-                        "You can always customize your workspace layout in your Settings",
-                    )),
-            )
-            .child(
-                h_flex()
-                    .w_full()
-                    .gap_1()
-                    .flex_wrap()
-                    .justify_end()
-                    .child(
-                        Button::new("learn", "Learn More")
-                            .label_size(LabelSize::Small)
-                            .style(ButtonStyle::OutlinedGhost)
-                            .on_click(move |_, _, cx| {
-                                cx.open_url(&zed_urls::parallel_agents_blog(cx))
-                            }),
-                    )
-                    .child(primary_button),
-            )
-            .child(dismiss_button);
-
-        AgentPanelOnboardingCard::new().child(content)
-    }
-}
-
-impl Component for AgentLayoutOnboarding {
-    fn scope() -> ComponentScope {
-        ComponentScope::Onboarding
-    }
-
-    fn name() -> &'static str {
-        "Agent Layout Onboarding"
-    }
-
-    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
-        let onboarding = cx.new(|_cx| AgentLayoutOnboarding {
-            use_agent_layout: Arc::new(|_, _| {}),
-            revert_to_editor_layout: Arc::new(|_, _| {}),
-            dismissed: Arc::new(|_, _| {}),
-            is_agent_layout: false,
-        });
-
-        Some(
-            v_flex()
-                .min_w_0()
-                .gap_4()
-                .child(single_example(
-                    "Agent Layout Onboarding",
-                    div()
-                        .w_full()
-                        .min_w_40()
-                        .max_w(px(1100.))
-                        .child(onboarding)
-                        .into_any_element(),
-                ))
-                .into_any_element(),
-        )
-    }
-}

crates/auto_update_ui/Cargo.toml 🔗

@@ -19,6 +19,7 @@ client.workspace = true
 db.workspace = true
 fs.workspace = true
 editor.workspace = true
+notifications.workspace = true
 gpui.workspace = true
 markdown_preview.workspace = true
 release_channel.workspace = true

crates/auto_update_ui/src/auto_update_ui.rs 🔗

@@ -9,6 +9,7 @@ use gpui::{
     App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
 };
 use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
+use notifications::status_toast::StatusToast;
 use release_channel::{AppVersion, ReleaseChannel};
 use semver::Version;
 use serde::Deserialize;
@@ -207,17 +208,17 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementC
         let fs = <dyn Fs>::global(cx);
         Some(AnnouncementContent {
             heading: "Introducing Parallel Agents".into(),
-            description: "Run multiple agent threads simultaneously across projects.".into(),
+            description: "Run multiple threads of your favorite agents simultaneously across projects in a new workspace layout, tailored for agentic workflows.".into(),
             bullet_items: vec![
                 "Use your favorite agents in parallel".into(),
                 "Optionally isolate agents using worktrees".into(),
                 "Combine multiple projects in one window".into(),
             ],
-            primary_action_label: "Try Now".into(),
+            primary_action_label: "Try Agentic Layout".into(),
             primary_action_url: None,
             primary_action_callback: Some(Arc::new(move |window, cx| {
-                let already_agent_layout =
-                    matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
+                let get_layout = AgentSettings::get_layout(cx);
+                let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_));
 
                 let update;
                 if !already_agent_layout {
@@ -230,6 +231,7 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementC
                     update = None;
                 }
 
+                let revert_fs = fs.clone();
                 window
                     .spawn(cx, async move |cx| {
                         if let Some(update) = update {
@@ -237,6 +239,35 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementC
                         }
 
                         cx.update(|window, cx| {
+                            if !already_agent_layout {
+                                if let Some(workspace) = Workspace::for_window(window, cx) {
+                                    let toast = StatusToast::new(
+                                        "You are in the new agentic layout!",
+                                        cx,
+                                        move |this, _cx| {
+                                            this.icon(
+                                                Icon::new(IconName::Check)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Success),
+                                            )
+                                            .action("Revert", move |_window, cx| {
+                                                let _ = AgentSettings::set_layout(
+                                                    get_layout.clone(),
+                                                    revert_fs.clone(),
+                                                    cx,
+                                                );
+                                            })
+                                            .auto_dismiss(false)
+                                            .dismiss_button(true)
+                                        },
+                                    );
+
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.toggle_status_toast(toast, cx);
+                                    });
+                                }
+                            }
+
                             window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
                             window.dispatch_action(Box::new(FocusAgent), cx);
                         })
@@ -381,8 +412,10 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
     }
 
     let should_show_notification = updater.read(cx).should_show_update_notification(cx);
+
     cx.spawn(async move |cx| {
         let should_show_notification = should_show_notification.await?;
+
         if should_show_notification {
             cx.update(|cx| {
                 show_update_notification(cx);

crates/component_preview/src/component_preview.rs 🔗

@@ -8,7 +8,7 @@ use gpui::{
 };
 use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
 use language::LanguageRegistry;
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use persistence::ComponentPreviewDb;
 use project::Project;
 use std::{iter::Iterator, ops::Range, sync::Arc};
@@ -561,10 +561,14 @@ impl ComponentPreview {
             workspace.update(cx, |workspace, cx| {
                 let status_toast =
                     StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
-                        this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
-                            .action("Open Pull Request", |_, cx| {
-                                cx.open_url("https://github.com/")
-                            })
+                        this.icon(
+                            Icon::new(IconName::GitBranch)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .action("Open Pull Request", |_, cx| {
+                            cx.open_url("https://github.com/")
+                        })
                     });
                 workspace.toggle_status_toast(status_toast, cx)
             });

crates/debugger_ui/src/session/running/memory_view.rs 🔗

@@ -14,7 +14,7 @@ use gpui::{
     Subscription, Task, TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions,
     anchored, deferred, uniform_list,
 };
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session};
 use settings::Settings;
 use theme_settings::ThemeSettings;
@@ -480,7 +480,7 @@ impl MemoryView {
                                             cx.emit(DismissEvent)
                                         });
                                     }).detach();
-                                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                                    this.icon(Icon::new(IconName::XCircle).size(IconSize::Small).color(Color::Error))
                                 }),
                                 cx,
                             );

crates/git_ui/src/clone.rs 🔗

@@ -1,7 +1,7 @@
 use gpui::{App, Context, WeakEntity, Window};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use std::sync::Arc;
-use ui::{Color, IconName, SharedString};
+use ui::{Color, Icon, IconName, IconSize, SharedString};
 use util::ResultExt;
 use workspace::{self, Workspace};
 
@@ -48,8 +48,12 @@ pub fn clone_and_open(
                 workspace
                     .update(cx, |workspace, cx| {
                         let toast = StatusToast::new(error.to_string(), cx, |this, _| {
-                            this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                                .dismiss_button(true)
+                            this.icon(
+                                Icon::new(IconName::XCircle)
+                                    .size(IconSize::Small)
+                                    .color(Color::Error),
+                            )
+                            .dismiss_button(true)
                         });
                         workspace.toggle_status_toast(toast, cx);
                     })

crates/git_ui/src/git_panel.rs 🔗

@@ -50,7 +50,7 @@ use language_model::{
 };
 use menu;
 use multi_buffer::ExcerptBoundaryInfo;
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
 use project::{
     Fs, Project, ProjectPath,
@@ -3864,9 +3864,17 @@ impl GitPanel {
             let status_toast = StatusToast::new(message, cx, move |this, _cx| {
                 use remote_output::SuccessStyle::*;
                 match style {
-                    Toast => this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)),
+                    Toast => this.icon(
+                        Icon::new(IconName::GitBranch)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
                     ToastWithLog { output } => this
-                        .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
+                        .icon(
+                            Icon::new(IconName::GitBranch)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .action("View Log", move |window, cx| {
                             let output = output.clone();
                             let output =
@@ -3878,7 +3886,11 @@ impl GitPanel {
                                 .ok();
                         }),
                     PushPrLink { text, link } => this
-                        .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
+                        .icon(
+                            Icon::new(IconName::GitBranch)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .action(text, move |_, cx| cx.open_url(&link)),
                 }
                 .dismiss_button(true)
@@ -6479,16 +6491,20 @@ pub(crate) fn show_error_toast(
         workspace.update(cx, |workspace, cx| {
             let workspace_weak = cx.weak_entity();
             let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
-                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                    .action("View Log", move |window, cx| {
-                        let message = message.clone();
-                        let action = action.clone();
-                        workspace_weak
-                            .update(cx, move |workspace, cx| {
-                                open_output(action, workspace, &message, window, cx)
-                            })
-                            .ok();
-                    })
+                this.icon(
+                    Icon::new(IconName::XCircle)
+                        .size(IconSize::Small)
+                        .color(Color::Error),
+                )
+                .action("View Log", move |window, cx| {
+                    let message = message.clone();
+                    let action = action.clone();
+                    workspace_weak
+                        .update(cx, move |workspace, cx| {
+                            open_output(action, workspace, &message, window, cx)
+                        })
+                        .ok();
+                })
             });
             workspace.toggle_status_toast(toast, cx)
         });

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -25,7 +25,7 @@ use gpui::{
 };
 use language::{Language, LanguageConfig, ToOffset as _};
 
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use project::{CompletionDisplayOptions, Project};
 use settings::{
     BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size,
@@ -2883,8 +2883,12 @@ impl KeybindingEditorModal {
                                 format!("Saved edits to the {} action.", humanized_action_name),
                                 cx,
                                 move |this, _cx| {
-                                    this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
-                                        .dismiss_button(true)
+                                    this.icon(
+                                        Icon::new(IconName::Check)
+                                            .size(IconSize::Small)
+                                            .color(Color::Success),
+                                    )
+                                    .dismiss_button(true)
                                     // .action("Undo", f) todo: wire the undo functionality
                                 },
                             );

crates/notifications/src/status_toast.rs 🔗

@@ -5,41 +5,13 @@ use ui::{Tooltip, prelude::*};
 use workspace::{ToastAction, ToastView};
 use zed_actions::toast;
 
-#[derive(Clone, Copy)]
-pub struct ToastIcon {
-    icon: IconName,
-    color: Color,
-}
-
-impl ToastIcon {
-    pub fn new(icon: IconName) -> Self {
-        Self {
-            icon,
-            color: Color::default(),
-        }
-    }
-
-    pub fn color(mut self, color: Color) -> Self {
-        self.color = color;
-        self
-    }
-}
-
-impl From<IconName> for ToastIcon {
-    fn from(icon: IconName) -> Self {
-        Self {
-            icon,
-            color: Color::default(),
-        }
-    }
-}
-
 #[derive(RegisterComponent)]
 pub struct StatusToast {
-    icon: Option<ToastIcon>,
+    icon: Option<Icon>,
     text: SharedString,
     action: Option<ToastAction>,
     show_dismiss: bool,
+    auto_dismiss: bool,
     this_handle: Entity<Self>,
     focus_handle: FocusHandle,
 }
@@ -59,6 +31,7 @@ impl StatusToast {
                     icon: None,
                     action: None,
                     show_dismiss: false,
+                    auto_dismiss: true,
                     this_handle: cx.entity(),
                     focus_handle,
                 },
@@ -67,11 +40,16 @@ impl StatusToast {
         })
     }
 
-    pub fn icon(mut self, icon: ToastIcon) -> Self {
+    pub fn icon(mut self, icon: Icon) -> Self {
         self.icon = Some(icon);
         self
     }
 
+    pub fn auto_dismiss(mut self, auto_dismiss: bool) -> Self {
+        self.auto_dismiss = auto_dismiss;
+        self
+    }
+
     pub fn action(
         mut self,
         label: impl Into<SharedString>,
@@ -116,9 +94,7 @@ impl Render for StatusToast {
             .flex_none()
             .bg(cx.theme().colors().surface_background)
             .shadow_lg()
-            .when_some(self.icon.as_ref(), |this, icon| {
-                this.child(Icon::new(icon.icon).color(icon.color))
-            })
+            .when_some(self.icon.clone(), |this, icon| this.child(icon))
             .child(Label::new(self.text.clone()).color(Color::Default))
             .when_some(self.action.as_ref(), |this, action| {
                 this.child(
@@ -155,6 +131,10 @@ impl ToastView for StatusToast {
     fn action(&self) -> Option<ToastAction> {
         self.action.clone()
     }
+
+    fn auto_dismiss(&self) -> bool {
+        self.auto_dismiss
+    }
 }
 
 impl Focusable for StatusToast {
@@ -183,33 +163,55 @@ impl Component for StatusToast {
         let icon_example = StatusToast::new(
             "Nathan Sobo accepted your contact request",
             cx,
-            |this, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)),
+            |this, _| {
+                this.icon(
+                    Icon::new(IconName::Check)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+            },
         );
 
         let success_example = StatusToast::new("Pushed 4 changes to `zed/main`", cx, |this, _| {
-            this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
+            this.icon(
+                Icon::new(IconName::Check)
+                    .size(IconSize::Small)
+                    .color(Color::Success),
+            )
         });
 
         let error_example = StatusToast::new(
             "git push: Couldn't find remote origin `iamnbutler/zed`",
             cx,
             |this, _cx| {
-                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                    .action("More Info", |_, _| {})
+                this.icon(
+                    Icon::new(IconName::XCircle)
+                        .size(IconSize::Small)
+                        .color(Color::Error),
+                )
+                .action("More Info", |_, _| {})
             },
         );
 
         let warning_example = StatusToast::new("You have outdated settings", cx, |this, _cx| {
-            this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
-                .action("More Info", |_, _| {})
+            this.icon(
+                Icon::new(IconName::Warning)
+                    .size(IconSize::Small)
+                    .color(Color::Warning),
+            )
+            .action("More Info", |_, _| {})
         });
 
         let pr_example =
             StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
-                this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
-                    .action("Open Pull Request", |_, cx| {
-                        cx.open_url("https://github.com/")
-                    })
+                this.icon(
+                    Icon::new(IconName::GitBranch)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .action("Open Pull Request", |_, cx| {
+                    cx.open_url("https://github.com/")
+                })
             });
 
         Some(

crates/onboarding/src/onboarding.rs 🔗

@@ -7,7 +7,7 @@ use gpui::{
     FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString,
     Subscription, Task, WeakEntity, Window, actions,
 };
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{SettingsStore, VsCodeSettingsSource};
@@ -495,8 +495,12 @@ pub async fn handle_import_vscode_settings(
                     format!("Your {} settings were successfully imported.", source),
                     cx,
                     |this, _| {
-                        this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
-                            .dismiss_button(true)
+                        this.icon(
+                            Icon::new(IconName::Check)
+                                .size(IconSize::Small)
+                                .color(Color::Success),
+                        )
+                        .dismiss_button(true)
                     },
                 );
                 SettingsImportState::update(cx, |state, _| match source {
@@ -514,11 +518,15 @@ pub async fn handle_import_vscode_settings(
                     "Failed to import settings. See log for details",
                     cx,
                     |this, _| {
-                        this.icon(ToastIcon::new(IconName::Close).color(Color::Error))
-                            .action("Open Log", |window, cx| {
-                                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
-                            })
-                            .dismiss_button(true)
+                        this.icon(
+                            Icon::new(IconName::Close)
+                                .size(IconSize::Small)
+                                .color(Color::Error),
+                        )
+                        .action("Open Log", |window, cx| {
+                            window.dispatch_action(workspace::OpenLog.boxed_clone(), cx)
+                        })
+                        .dismiss_button(true)
                     },
                 );
                 workspace.toggle_status_toast(error_toast, cx);

crates/project_panel/src/project_panel.rs 🔗

@@ -31,7 +31,7 @@ use gpui::{
 };
 use language::DiagnosticSeverity;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use notifications::status_toast::{StatusToast, ToastIcon};
+use notifications::status_toast::StatusToast;
 use project::{
     Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
     ProjectPath, Worktree, WorktreeId,
@@ -2275,8 +2275,12 @@ impl ProjectPanel {
                         .update(cx, |panel, cx| {
                             let message = format!("Failed to restore {}: {}", file_name, e);
                             let toast = StatusToast::new(message, cx, |this, _| {
-                                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                                    .dismiss_button(true)
+                                this.icon(
+                                    Icon::new(IconName::XCircle)
+                                        .size(IconSize::Small)
+                                        .color(Color::Error),
+                                )
+                                .dismiss_button(true)
                             });
                             panel
                                 .workspace

crates/ui/src/components/ai/parallel_agents_illustration.rs 🔗

@@ -15,32 +15,39 @@ impl RenderOnce for ParallelAgentsIllustration {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         let icon_container = || h_flex().size_4().flex_shrink_0().justify_center();
 
-        let title_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| {
+        let loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| {
             div()
-                .h_2()
+                .h(rems_from_px(5.))
                 .w(width)
                 .rounded_full()
-                .debug_bg_blue()
                 .bg(cx.theme().colors().element_selected)
                 .with_animation(
                     id,
                     Animation::new(Duration::from_millis(duration_ms))
                         .repeat()
-                        .with_easing(pulsating_between(0.4, 0.8)),
+                        .with_easing(pulsating_between(0.1, 0.8)),
                     |label, delta| label.opacity(delta),
                 )
         };
 
+        let skeleton_bar = |width: DefiniteLength| {
+            div().h(rems_from_px(5.)).w(width).rounded_full().bg(cx
+                .theme()
+                .colors()
+                .text_muted
+                .opacity(0.05))
+        };
+
         let time =
             |time: SharedString| Label::new(time).size(LabelSize::XSmall).color(Color::Muted);
 
         let worktree = |worktree: SharedString| {
             h_flex()
-                .gap_1()
+                .gap_0p5()
                 .child(
                     Icon::new(IconName::GitWorktree)
                         .color(Color::Muted)
-                        .size(IconSize::XSmall),
+                        .size(IconSize::Indicator),
                 )
                 .child(
                     Label::new(worktree)
@@ -56,51 +63,53 @@ impl RenderOnce for ParallelAgentsIllustration {
                 .alpha(0.5)
         };
 
-        let agent = |id: &'static str,
-                     icon: IconName,
-                     width: DefiniteLength,
-                     duration_ms: u64,
-                     data: Vec<AnyElement>| {
+        let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec<AnyElement>| {
             v_flex()
-                .p_2()
+                .when(selected, |this| {
+                    this.bg(cx.theme().colors().element_active.opacity(0.2))
+                })
+                .p_1()
                 .child(
                     h_flex()
                         .w_full()
-                        .gap_2()
+                        .gap_1()
                         .child(
                             icon_container()
-                                .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)),
+                                .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
                         )
-                        .child(title_bar(id, width, duration_ms)),
+                        .map(|this| {
+                            if selected {
+                                this.child(
+                                    Label::new(title)
+                                        .color(Color::Muted)
+                                        .size(LabelSize::XSmall),
+                                )
+                            } else {
+                                this.child(skeleton_bar(relative(0.7)))
+                            }
+                        }),
                 )
                 .child(
                     h_flex()
                         .opacity(0.8)
                         .w_full()
-                        .gap_2()
+                        .gap_1()
                         .child(icon_container())
                         .children(data),
                 )
         };
 
         let agents = v_flex()
-            .absolute()
-            .w(rems_from_px(380.))
-            .top_8()
-            .rounded_t_sm()
-            .border_1()
-            .border_color(cx.theme().colors().border.opacity(0.5))
+            .col_span(3)
             .bg(cx.theme().colors().elevated_surface_background)
-            .shadow_md()
             .child(agent(
-                "zed-agent-bar",
+                "Fix branch label".into(),
                 IconName::ZedAgent,
-                relative(0.7),
-                1800,
+                true,
                 vec![
-                    worktree("happy-tree".into()).into_any_element(),
+                    worktree("bug-fix".into()).into_any_element(),
                     dot_separator().into_any_element(),
-                    DiffStat::new("ds", 23, 13)
+                    DiffStat::new("ds", 5, 2)
                         .label_size(LabelSize::XSmall)
                         .into_any_element(),
                     dot_separator().into_any_element(),
@@ -109,10 +118,9 @@ impl RenderOnce for ParallelAgentsIllustration {
             ))
             .child(Divider::horizontal())
             .child(agent(
-                "claude-bar",
+                "Improve thread id".into(),
                 IconName::AiClaude,
-                relative(0.85),
-                2400,
+                false,
                 vec![
                     DiffStat::new("ds", 120, 84)
                         .label_size(LabelSize::XSmall)
@@ -123,27 +131,142 @@ impl RenderOnce for ParallelAgentsIllustration {
             ))
             .child(Divider::horizontal())
             .child(agent(
-                "openai-bar",
+                "Refactor archive view".into(),
                 IconName::AiOpenAi,
-                relative(0.4),
-                3100,
+                false,
                 vec![
                     worktree("silent-forest".into()).into_any_element(),
                     dot_separator().into_any_element(),
                     time("37m".into()).into_any_element(),
                 ],
-            ))
-            .child(Divider::horizontal());
+            ));
+
+        let thread_view = v_flex()
+            .col_span(3)
+            .h_full()
+            .flex_1()
+            .border_l_1()
+            .border_color(cx.theme().colors().border.opacity(0.5))
+            .bg(cx.theme().colors().panel_background)
+            .child(
+                h_flex()
+                    .px_1p5()
+                    .py_0p5()
+                    .w_full()
+                    .justify_between()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border.opacity(0.5))
+                    .child(
+                        Label::new("Fix branch label")
+                            .size(LabelSize::XSmall)
+                            .color(Color::Muted),
+                    )
+                    .child(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Indicator)
+                            .color(Color::Muted),
+                    ),
+            )
+            .child(
+                div().p_1().child(
+                    v_flex()
+                        .px_1()
+                        .py_1p5()
+                        .gap_1()
+                        .border_1()
+                        .border_color(cx.theme().colors().border.opacity(0.5))
+                        .bg(cx.theme().colors().editor_background)
+                        .rounded_sm()
+                        .shadow_sm()
+                        .child(skeleton_bar(relative(0.7)))
+                        .child(skeleton_bar(relative(0.2))),
+                ),
+            )
+            .child(
+                v_flex()
+                    .p_2()
+                    .gap_1()
+                    .child(loading_bar("a", relative(0.55), 2200))
+                    .child(loading_bar("b", relative(0.75), 2000))
+                    .child(loading_bar("c", relative(0.25), 2400)),
+            );
+
+        let file_row = |indent: usize, is_folder: bool, bar_width: Rems| {
+            let indent_px = rems_from_px((indent as f32) * 4.0);
+
+            h_flex()
+                .px_2()
+                .py_px()
+                .gap_1()
+                .pl(indent_px)
+                .child(
+                    icon_container().child(
+                        Icon::new(if is_folder {
+                            IconName::FolderOpen
+                        } else {
+                            IconName::FileRust
+                        })
+                        .size(IconSize::Indicator)
+                        .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))),
+                    ),
+                )
+                .child(
+                    div().h_1p5().w(bar_width).rounded_sm().bg(cx
+                        .theme()
+                        .colors()
+                        .text
+                        .opacity(if is_folder { 0.15 } else { 0.1 })),
+                )
+        };
+
+        let project_panel = v_flex()
+            .col_span(1)
+            .h_full()
+            .flex_1()
+            .border_l_1()
+            .border_color(cx.theme().colors().border.opacity(0.5))
+            .bg(cx.theme().colors().panel_background)
+            .child(
+                v_flex()
+                    .child(file_row(0, true, rems_from_px(42.0)))
+                    .child(file_row(1, true, rems_from_px(28.0)))
+                    .child(file_row(2, false, rems_from_px(52.0)))
+                    .child(file_row(2, false, rems_from_px(36.0)))
+                    .child(file_row(2, false, rems_from_px(44.0)))
+                    .child(file_row(1, true, rems_from_px(34.0)))
+                    .child(file_row(2, false, rems_from_px(48.0)))
+                    .child(file_row(2, true, rems_from_px(26.0)))
+                    .child(file_row(3, false, rems_from_px(40.0)))
+                    .child(file_row(3, false, rems_from_px(56.0)))
+                    .child(file_row(1, false, rems_from_px(38.0)))
+                    .child(file_row(0, true, rems_from_px(30.0)))
+                    .child(file_row(1, false, rems_from_px(46.0)))
+                    .child(file_row(1, false, rems_from_px(32.0))),
+            );
+
+        let workspace = div()
+            .absolute()
+            .top_8()
+            .grid()
+            .grid_cols(7)
+            .w(rems_from_px(380.))
+            .rounded_t_sm()
+            .border_1()
+            .border_color(cx.theme().colors().border.opacity(0.5))
+            .shadow_md()
+            .child(agents)
+            .child(thread_view)
+            .child(project_panel);
 
         h_flex()
             .relative()
             .h(rems_from_px(180.))
-            .bg(cx.theme().colors().editor_background)
+            .bg(cx.theme().colors().editor_background.opacity(0.6))
             .justify_center()
             .items_end()
             .rounded_t_md()
             .overflow_hidden()
             .bg(gpui::black().opacity(0.2))
-            .child(agents)
+            .child(workspace)
     }
 }

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

@@ -113,6 +113,7 @@ impl From<IconName> for Icon {
 }
 
 /// The source of an icon.
+#[derive(Clone)]
 enum IconSource {
     /// An SVG embedded in the Zed binary.
     Embedded(SharedString),
@@ -126,7 +127,7 @@ enum IconSource {
     ExternalSvg(SharedString),
 }
 
-#[derive(IntoElement, RegisterComponent)]
+#[derive(Clone, IntoElement, RegisterComponent)]
 pub struct Icon {
     source: IconSource,
     color: Color,

crates/workspace/src/toast_layer.rs 🔗

@@ -44,6 +44,10 @@ pub fn init(cx: &mut App) {
 
 pub trait ToastView: ManagedView {
     fn action(&self) -> Option<ToastAction>;
+
+    fn auto_dismiss(&self) -> bool {
+        true
+    }
 }
 
 #[derive(Clone)]
@@ -131,6 +135,7 @@ impl ToastLayer {
         V: ToastView,
     {
         let action = new_toast.read(cx).action();
+        let auto_dismiss = new_toast.read(cx).auto_dismiss();
         let focus_handle = cx.focus_handle();
 
         self.active_toast = Some(ActiveToast {
@@ -143,7 +148,9 @@ impl ToastLayer {
             focus_handle,
         });
 
-        self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx);
+        if auto_dismiss {
+            self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx);
+        }
 
         cx.notify();
     }