From 74de476364ba6da625e1cc8468bffbf23d037657 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:51:15 -0300 Subject: [PATCH] Simplify parallel agents onboarding (#53854) - 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 --- Cargo.lock | 1 + crates/agent_ui/src/agent_configuration.rs | 68 +++--- .../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 +++- .../src/component_preview.rs | 14 +- .../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 +- .../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(-) diff --git a/Cargo.lock b/Cargo.lock index 6c86de99d77e2e0e9dc9cd726eac37f7115bcdf2..aa6ed80b334d48d5d0be432f7df77e4435003cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1251,6 +1251,7 @@ dependencies = [ "fs", "gpui", "markdown_preview", + "notifications", "release_channel", "semver", "serde", diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fda3cb9907b2f02cce29ff0ae8c4762e6efa625a..13ec53b25c50b5865c0070daee76d7eadde10c7b 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/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(); + } + }) }, ); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7..465d31b416e9e85a4fc94a0b7f507c7560bf422a 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/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", |_, _| {}) }, ); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5bb994dadf407e018b3be1ad6fe5d33ef0ed5a23..15912773167c560f8e8158ed9237159a4b341e8c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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>>, new_user_onboarding: Entity, new_user_onboarding_upsell_dismissed: AtomicBool, - agent_layout_onboarding: Entity, - 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) -> 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, - ) -> Option { - 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.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.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 { diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 08aaed78a7baa6453213d16371f579472e72f91e..99ab121027ae2e9a61a0a85b6333425ba02354cc 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/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) }) }; diff --git a/crates/agent_ui/src/ui/undo_reject_toast.rs b/crates/agent_ui/src/ui/undo_reject_toast.rs index 90c8a9c7ea98edd56ca935eddb36206a83bcc4bc..97352fa67d690d5770442c662e7e3145dcf25bf5 100644 --- a/crates/agent_ui/src/ui/undo_reject_toast.rs +++ b/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); } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index c49558e50472f3b497ba74ac388f3874aceec77b..147458923045c15faf926a1ad4424b666b5204d8 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/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, - pub revert_to_editor_layout: Arc, - pub dismissed: Arc, - pub is_agent_layout: bool, -} - -impl Render for AgentLayoutOnboarding { - fn render(&mut self, _window: &mut ui::Window, _cx: &mut Context) -> 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 { - 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(), - ) - } -} diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index b7b51c4a28448434ec4483f898e2d67b3301533e..16783aa9e963ee314776d57712fc343e8227f94f 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/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 diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index ec199c18a3179ef70c9092cb737dd22c4514ceac..ebd0d2c06dc977a0e0b7cfadd8d787a11ec81e28 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/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::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 Option 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) }); diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index c4833620cf4ec0a6dc965aa9e23c2690a44773fd..70d6f326a5fd0a17c306c8a8af4645d8e234f837 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/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 }, ); diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index 8c177bfe9ca66a81af2b4441b6c4703e9d871395..d9ffcddc6ffe6539723e04d37749b7c7f03b1ca1 100644 --- a/crates/notifications/src/status_toast.rs +++ b/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 for ToastIcon { - fn from(icon: IconName) -> Self { - Self { - icon, - color: Color::default(), - } - } -} - #[derive(RegisterComponent)] pub struct StatusToast { - icon: Option, + icon: Option, text: SharedString, action: Option, show_dismiss: bool, + auto_dismiss: bool, this_handle: Entity, 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, @@ -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 { 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( diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index caa1a5458f66f77a46665627731f720a47a0cdbd..4a6a3c821cdb3ae5fc03b2711a39176bd3d432d9 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/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); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b409962d9fd20621ad4c1153ab723cf9e08d85a0..3a5047c0d7a6d40968ca7b5d10f65e317dbc92a8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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 diff --git a/crates/ui/src/components/ai/parallel_agents_illustration.rs b/crates/ui/src/components/ai/parallel_agents_illustration.rs index 3640f71c075b3ba8f8cfb24fbde0b583c3763ab8..e7694e9359f3d9ceaebcd92ad32dfa5bd6705dea 100644 --- a/crates/ui/src/components/ai/parallel_agents_illustration.rs +++ b/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| { + let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec| { 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) } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index a0e880af5e7029cb08670d151647489db1d05f4f..d9a1553b7da442090668d6dc06c91431ede3f4ad 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -113,6 +113,7 @@ impl From 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, diff --git a/crates/workspace/src/toast_layer.rs b/crates/workspace/src/toast_layer.rs index 5979c376f6542b0429eadd40622efa1f5ea56325..47759645e5b4b3051ce90efbb60f049189ba4c26 100644 --- a/crates/workspace/src/toast_layer.rs +++ b/crates/workspace/src/toast_layer.rs @@ -44,6 +44,10 @@ pub fn init(cx: &mut App) { pub trait ToastView: ManagedView { fn action(&self) -> Option; + + 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(); }