use std::sync::Arc; use agent_skills::GLOBAL_SKILLS_DIR_DISPLAY; use auto_update::{AutoUpdater, release_notes_url}; use client::zed_urls; use db::kvp::Dismissable; use editor::{Editor, MultiBuffer}; use gpui::{ App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TaskExt, Window, actions, prelude::*, }; use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView}; use prompt_store::rules_to_skills_migration; use release_channel::{AppVersion, ReleaseChannel}; use semver::Version; use serde::Deserialize; use smol::io::AsyncReadExt; use ui::{AnnouncementToast, ListBulletItem, SkillsIllustration, prelude::*}; use util::{ResultExt as _, maybe}; use workspace::{ Workspace, notifications::{ Notification, NotificationId, SuppressEvent, show_app_notification, simple_message_notification::MessageNotification, }, workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}, }; use zed_actions::ShowUpdateNotification; actions!( auto_update, [ /// Opens the release notes for the current version in a new tab. ViewReleaseNotesLocally ] ); pub fn init(cx: &mut App) { notify_if_app_was_updated(cx); cx.observe_new(|workspace: &mut Workspace, _window, cx| { workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, window, cx| { view_release_notes_locally(workspace, window, cx); }); if matches!( ReleaseChannel::global(cx), ReleaseChannel::Nightly | ReleaseChannel::Dev ) { workspace.register_action(|_workspace, _: &ShowUpdateNotification, _window, cx| { show_update_notification(cx); }); } }) .detach(); } #[derive(Deserialize)] struct ReleaseNotesBody { title: String, release_notes: String, } fn notify_release_notes_failed_to_show( workspace: &mut Workspace, _window: &mut Window, cx: &mut Context, ) { let url = release_notes_url(cx); struct ReleaseNotesError { url: Option, } impl WorkspaceError for ReleaseNotesError { fn primary_message(&self) -> SharedString { "Couldn't load release notes".into() } fn severity(&self) -> ErrorSeverity { ErrorSeverity::Error } fn primary_action(&self) -> ErrorAction { self.url .clone() .map(|url| ErrorAction::link("View in Browser", url)) .unwrap_or_else(ErrorAction::dismiss) } } workspace.show_error(ReleaseNotesError { url }, cx); } fn view_release_notes_locally( workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { let release_channel = ReleaseChannel::global(cx); if matches!( release_channel, ReleaseChannel::Nightly | ReleaseChannel::Dev ) { if let Some(url) = release_notes_url(cx) { cx.open_url(&url); } return; } let version = AppVersion::global(cx).to_string(); let client = client::Client::global(cx).http_client(); let url = client.build_url(&format!( "/api/release_notes/v2/{}/{}", release_channel.dev_name(), version )); let markdown = workspace .app_state() .languages .language_for_name("Markdown"); cx.spawn_in(window, async move |workspace, cx| { let markdown = markdown.await.log_err(); let response = client.get(&url, Default::default(), true).await; let Some(mut response) = response.log_err() else { workspace .update_in(cx, notify_release_notes_failed_to_show) .log_err(); return; }; let mut body = Vec::new(); response.body_mut().read_to_end(&mut body).await.ok(); let body: serde_json::Result = serde_json::from_slice(body.as_slice()); let res: Option<()> = maybe!(async { let body = body.ok()?; let project = workspace .read_with(cx, |workspace, _| workspace.project().clone()) .ok()?; let (language_registry, buffer) = project.update(cx, |project, cx| { ( project.languages().clone(), project.create_buffer(markdown, false, cx), ) }); let buffer = buffer.await.ok()?; buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, body.release_notes)], None, cx) }); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(body.title)); let ws_handle = workspace.clone(); workspace .update_in(cx, |workspace, window, cx| { let editor = cx.new(|cx| Editor::for_multibuffer(buffer, Some(project), window, cx)); let markdown_preview: Entity = MarkdownPreviewView::new( MarkdownPreviewMode::Default, editor, ws_handle, language_registry, window, cx, ); workspace.add_item_to_active_pane( Box::new(markdown_preview), None, true, window, cx, ); cx.notify(); }) .ok() }) .await; if res.is_none() { workspace .update_in(cx, notify_release_notes_failed_to_show) .log_err(); } }) .detach(); } #[derive(Clone)] struct AnnouncementContent { heading: SharedString, description: SharedString, bullet_items: Vec, primary_action_label: SharedString, secondary_action_label: SharedString, primary_action_url: Option, primary_action_callback: Option>, secondary_action_url: Option, on_dismiss: Option>, } struct SkillsAnnouncement; impl Dismissable for SkillsAnnouncement { const KEY: &'static str = "skills_announcement_dismissed"; } fn announcement_for_version(version: &Version, cx: &App) -> Option { let version_with_skills = match ReleaseChannel::global(cx) { ReleaseChannel::Stable => Version::new(1, 4, 0), ReleaseChannel::Dev | ReleaseChannel::Nightly | ReleaseChannel::Preview => { Version::new(1, 4, 0) } }; if *version >= version_with_skills && !SkillsAnnouncement::dismissed(cx) { // Only mention the Rules → Skills migration if the user actually // had Rules that got migrated. New users (and existing users who // never created a Rule) would otherwise be confused by a bullet // referring to "your rules" that don't exist. let migrated_anything = rules_to_skills_migration::migration_result().is_some_and(|result| !result.is_empty()); let mut bullet_items: Vec = Vec::with_capacity(3); bullet_items .push(format!("Skills live in {GLOBAL_SKILLS_DIR_DISPLAY}//SKILL.md").into()); bullet_items.push("Type / to manually invoke a skill".into()); if migrated_anything { bullet_items.push( "The Rules Library is making way for skills: your default rules are now in a global AGENTS.md, and your other rules have been converted to skills".into(), ); } Some(AnnouncementContent { heading: "Introducing Skills Support".into(), description: "Extend the agent with focused instructions and domain knowledge.".into(), bullet_items, primary_action_label: "Try Now".into(), secondary_action_label: "Read Documentation".into(), primary_action_url: None, primary_action_callback: Some(Arc::new(move |window, cx| { window.dispatch_action(Box::new(zed_actions::assistant::FocusAgent), cx); })), on_dismiss: Some(Arc::new(|cx| SkillsAnnouncement::set_dismissed(true, cx))), secondary_action_url: Some(zed_urls::skills_docs(cx).into()), }) } else { None } } struct AnnouncementToastNotification { focus_handle: FocusHandle, content: AnnouncementContent, } impl AnnouncementToastNotification { fn new(content: AnnouncementContent, cx: &mut App) -> Self { Self { focus_handle: cx.focus_handle(), content, } } fn dismiss(&mut self, cx: &mut Context) { cx.emit(DismissEvent); if let Some(on_dismiss) = &self.content.on_dismiss { on_dismiss(cx); } } } impl Focusable for AnnouncementToastNotification { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for AnnouncementToastNotification {} impl EventEmitter for AnnouncementToastNotification {} impl Notification for AnnouncementToastNotification {} impl Render for AnnouncementToastNotification { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { AnnouncementToast::new() .illustration(SkillsIllustration::new()) .heading(self.content.heading.clone()) .description(self.content.description.clone()) .bullet_items( self.content .bullet_items .iter() .map(|item| ListBulletItem::new(item.clone())), ) .primary_action_label(self.content.primary_action_label.clone()) .secondary_action_label(self.content.secondary_action_label.clone()) .primary_on_click(cx.listener({ let url = self.content.primary_action_url.clone(); let callback = self.content.primary_action_callback.clone(); move |this, _, window, cx| { telemetry::event!("Skills Announcement Main Click"); if let Some(callback) = &callback { callback(window, cx); } if let Some(url) = &url { cx.open_url(url); } this.dismiss(cx); } })) .secondary_on_click(cx.listener({ let url = self.content.secondary_action_url.clone(); move |_, _, _window, cx| { telemetry::event!("Skills Announcement Secondary Click"); if let Some(url) = &url { cx.open_url(url); } } })) .dismiss_on_click(cx.listener(|this, _, _window, cx| { telemetry::event!("Skills Announcement Dismiss"); this.dismiss(cx); })) } } struct UpdateNotification; fn show_update_notification(cx: &mut App) { let Some(updater) = AutoUpdater::get(cx) else { return; }; let mut version = updater.read(cx).current_version(); version.pre = semver::Prerelease::EMPTY; version.build = semver::BuildMetadata::EMPTY; let app_name = ReleaseChannel::global(cx).display_name(); if let Some(content) = announcement_for_version(&version, cx) { show_app_notification( NotificationId::unique::(), cx, move |cx| cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx)), ); } else { show_app_notification( NotificationId::unique::(), cx, move |cx| { let workspace_handle = cx.entity().downgrade(); cx.new(|cx| { MessageNotification::new(format!("Updated to {app_name} {}", version), cx) .primary_message("View Release Notes") .primary_on_click(move |window, cx| { if let Some(workspace) = workspace_handle.upgrade() { workspace.update(cx, |workspace, cx| { crate::view_release_notes_locally(workspace, window, cx); }) } cx.emit(DismissEvent); }) .show_suppress_button(false) }) }, ); } } /// Shows a notification across all workspaces if an update was previously automatically installed /// and this notification had not yet been shown. pub fn notify_if_app_was_updated(cx: &mut App) { let Some(updater) = AutoUpdater::get(cx) else { return; }; if let ReleaseChannel::Nightly = ReleaseChannel::global(cx) { return; } 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); updater.update(cx, |updater, cx| { updater .set_should_show_update_notification(false, cx) .detach_and_log_err(cx); }); }); } anyhow::Ok(()) }) .detach(); }