Use the announcement toast for skills support (#56873)

Closes AI-244

This PR changes the approach for announcing skills support. It uses the
announcement toast, the same thing used for parallel agents, which
observes a specific version number to be displayed. I ended up
simplifying things a bit by thinking we could rely on documentation for
more detailed information (with more extensive paths and whatnot).

Release Notes:

- N/A

---------

Co-authored-by: Richard Feldman <richard@zed.dev>
Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
Co-authored-by: Martin Ye <martinye022@gmail.com>
This commit is contained in:
Danilo Leal 2026-05-19 22:26:06 -03:00 committed by GitHub
parent 2e70059cd9
commit 1ddf7407e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 150 additions and 618 deletions

6
Cargo.lock generated
View file

@ -1293,22 +1293,20 @@ dependencies = [
name = "auto_update_ui"
version = "0.1.0"
dependencies = [
"agent_settings",
"agent_skills",
"anyhow",
"auto_update",
"client",
"db",
"editor",
"fs",
"gpui",
"markdown_preview",
"notifications",
"project",
"prompt_store",
"release_channel",
"semver",
"serde",
"serde_json",
"settings",
"smol",
"telemetry",
"ui",

View file

@ -30,10 +30,10 @@ acp_thread.workspace = true
action_log.workspace = true
agent-client-protocol.workspace = true
agent.workspace = true
agent_skills.workspace = true
async-channel.workspace = true
agent_servers.workspace = true
agent_settings.workspace = true
agent_skills.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true
heapless.workspace = true

View file

@ -554,18 +554,6 @@ pub fn init(
);
})
.detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
|workspace: &mut Workspace,
_: &zed_actions::agent::OpenRulesToSkillsMigrationInfo,
window: &mut Window,
cx: &mut Context<Workspace>| {
crate::ui::RulesToSkillsModal::toggle(workspace, window, cx);
},
);
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(

View file

@ -333,8 +333,6 @@ pub struct ThreadView {
pub show_codex_windows_warning: bool,
pub multi_root_callout_dismissed: bool,
pub generating_indicator_in_list: bool,
/// Errors emitted by the agent while loading SKILL.md files. Each one
/// renders as a clickable banner that opens the offending file.
pub skill_loading_errors: Vec<SkillLoadingError>,
/// Errors the user has explicitly dismissed. Each entry is matched against
/// emitted errors by full equality; when an error no longer appears in the

View file

@ -3,7 +3,6 @@ mod end_trial_upsell;
mod hold_for_default;
mod mention_crease;
mod model_selector_components;
mod rules_to_skills_modal;
mod undo_reject_toast;
pub use agent_notification::*;
@ -11,7 +10,6 @@ pub use end_trial_upsell::*;
pub use hold_for_default::*;
pub use mention_crease::*;
pub use model_selector_components::*;
pub use rules_to_skills_modal::*;
pub use undo_reject_toast::*;
/// Returns the appropriate [`DocumentationSide`] for documentation asides

View file

@ -1,196 +0,0 @@
//! Mini modal shown when the user clicks the title-bar Skills
//! announcement banner. Renders one of two flavours depending on the
//! persisted [`MigrationResult`]:
//!
//! * **No rules migrated** (new user, or an existing user who never
//! touched Rules): a generic "Introducing Skills" intro that explains
//! what Skills are and how to invoke them.
//! * **Rules migrated**: a per-destination summary of exactly which
//! Rules ended up where (Skills directory, global AGENTS.md, top of
//! AGENTS.md for customized built-ins), capped at three names per
//! section with an "…and N more" overflow line.
use agent_skills::GLOBAL_SKILLS_DIR_DISPLAY;
use gpui::{
DismissEvent, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, Styled,
};
use paths::GLOBAL_AGENTS_FILE_DISPLAY;
use prompt_store::rules_to_skills_migration::{self, MigrationResult};
use ui::{
AlertModal, Button, ButtonCommon, ButtonStyle, Clickable, KeyBinding, ListBulletItem, h_flex,
prelude::*,
};
use workspace::{ModalView, Workspace};
/// Maximum number of rule names to list inline in the modal before
/// collapsing the rest into an "…and N more" line.
const MAX_LISTED_NAMES: usize = 3;
pub struct RulesToSkillsModal {
focus_handle: FocusHandle,
}
impl RulesToSkillsModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
workspace.toggle_modal(window, cx, |_window, cx| Self {
focus_handle: cx.focus_handle(),
});
}
fn dismiss(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl Focusable for RulesToSkillsModal {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for RulesToSkillsModal {}
impl ModalView for RulesToSkillsModal {}
impl Render for RulesToSkillsModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let result = rules_to_skills_migration::migration_result().unwrap_or_default();
let mut modal = AlertModal::new("rules-to-skills-migration")
.width(rems(28.))
.key_context("RulesToSkillsModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.dismiss(cx)))
.on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)));
if result.is_empty() {
modal = render_introducing_skills(modal);
} else {
modal = render_migration_summary(modal, &result);
}
// Both flavours close with the same invocation instructions.
modal = modal.child(Label::new(
"To include a Skill in a prompt, type /skill-name (or @-mention it).",
));
modal.footer(
h_flex().p_3().items_center().justify_end().child(
Button::new("got-it", "Got it")
.style(ButtonStyle::Filled)
.layer(ui::ElevationIndex::ModalSurface)
.key_binding(
KeyBinding::for_action(&menu::Confirm, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, _window, cx| {
this.dismiss(cx);
cx.stop_propagation();
})),
),
)
}
}
/// Render the modal body for users who had no Rules to migrate — a
/// generic introduction to the Skills feature.
fn render_introducing_skills(modal: AlertModal) -> AlertModal {
modal.title("Introducing Skills").child(Label::new(format!(
"Skills are reusable instructions for the agent, stored as Markdown files \
under {GLOBAL_SKILLS_DIR_DISPLAY}/<name>/SKILL.md."
)))
}
/// Render the modal body for users whose Rules were migrated, listing
/// each destination's contents (capped at [`MAX_LISTED_NAMES`] per
/// section).
fn render_migration_summary(mut modal: AlertModal, result: &MigrationResult) -> AlertModal {
modal = modal.title("Skills have replaced Rules");
if !result.skill_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Rules have been migrated to Skills in {GLOBAL_SKILLS_DIR_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.skill_names);
}
if !result.agents_md_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Default Rules were added to {GLOBAL_AGENTS_FILE_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.agents_md_names);
}
if !result.customized_builtins.is_empty() {
modal = modal.child(Label::new(customized_builtins_sentence(
&result.customized_builtins,
)));
}
modal
}
/// Append up to [`MAX_LISTED_NAMES`] bullet items naming individual
/// rules, plus a final "…and N more" bullet if the list is longer.
fn add_bulleted_names(mut modal: AlertModal, names: &[String]) -> AlertModal {
for name in names.iter().take(MAX_LISTED_NAMES) {
modal = modal.child(ListBulletItem::new(name.clone()));
}
if names.len() > MAX_LISTED_NAMES {
let extras = names.len() - MAX_LISTED_NAMES;
let label = if extras == 1 {
"…and 1 more".to_string()
} else {
format!("…and {extras} more")
};
modal = modal.child(ListBulletItem::new(label));
}
modal
}
/// Build the sentence describing any customized built-in prompts that
/// were prepended to AGENTS.md. Singular wording for the common
/// one-built-in case; comma-joined for the (currently hypothetical)
/// multi-built-in case.
fn customized_builtins_sentence(names: &[String]) -> String {
debug_assert!(
!names.is_empty(),
"caller should only invoke this for a non-empty list"
);
if names.len() == 1 {
format!(
"Your customization of the {name} built-in prompt has been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}.",
name = names[0],
)
} else {
format!(
"Your customizations of these built-in prompts have been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}: {joined}.",
joined = names.join(", "),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn customized_builtins_sentence_uses_singular_wording_for_one_item() {
let sentence = customized_builtins_sentence(&["Commit message".to_string()]);
assert!(sentence.contains("Your customization of the Commit message"));
assert!(sentence.contains("built-in prompt has been added"));
assert!(sentence.contains(GLOBAL_AGENTS_FILE_DISPLAY));
}
#[test]
fn customized_builtins_sentence_uses_plural_wording_for_multiple_items() {
let sentence = customized_builtins_sentence(&[
"Commit message".to_string(),
"Future Built-in".to_string(),
]);
assert!(sentence.contains("customizations of these built-in prompts"));
assert!(sentence.contains("Commit message, Future Built-in"));
}
}

View file

@ -12,22 +12,20 @@ workspace = true
path = "src/auto_update_ui.rs"
[dependencies]
agent_settings.workspace = true
agent_skills.workspace = true
anyhow.workspace = true
auto_update.workspace = true
client.workspace = true
db.workspace = true
editor.workspace = true
fs.workspace = true
gpui.workspace = true
markdown_preview.workspace = true
notifications.workspace = true
project.workspace = true
prompt_store.workspace = true
release_channel.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
telemetry.workspace = true
ui.workspace = true

View file

@ -1,31 +1,30 @@
use std::sync::Arc;
use agent_settings::{AgentSettings, WindowLayout};
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 fs::Fs;
use gpui::{
App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TaskExt, Window, actions,
prelude::*,
};
use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
use notifications::status_toast::StatusToast;
use prompt_store::rules_to_skills_migration;
use release_channel::{AppVersion, ReleaseChannel};
use semver::Version;
use serde::Deserialize;
use settings::Settings as _;
use smol::io::AsyncReadExt;
use ui::{AnnouncementToast, ListBulletItem, ParallelAgentsIllustration, prelude::*};
use ui::{AnnouncementToast, ListBulletItem, SkillsIllustration, prelude::*};
use util::{ResultExt as _, maybe};
use workspace::{
FocusWorkspaceSidebar, Workspace,
Workspace,
notifications::{
ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification,
simple_message_notification::MessageNotification,
},
};
use zed_actions::{ShowUpdateNotification, assistant::FocusAgent};
use zed_actions::ShowUpdateNotification;
actions!(
auto_update,
@ -186,103 +185,57 @@ struct AnnouncementContent {
description: SharedString,
bullet_items: Vec<SharedString>,
primary_action_label: SharedString,
secondary_action_label: SharedString,
primary_action_url: Option<SharedString>,
primary_action_callback: Option<Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>>,
secondary_action_url: Option<SharedString>,
on_dismiss: Option<Arc<dyn Fn(&mut App) + Send + Sync>>,
}
struct ParallelAgentAnnouncement;
struct SkillsAnnouncement;
impl Dismissable for ParallelAgentAnnouncement {
const KEY: &'static str = "parallel-agent-announcement";
impl Dismissable for SkillsAnnouncement {
const KEY: &'static str = "skills_announcement_dismissed";
}
fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementContent> {
let version_with_parallel_agents = match ReleaseChannel::global(cx) {
ReleaseChannel::Stable => Version::new(0, 233, 0),
let version_with_skills = match ReleaseChannel::global(cx) {
ReleaseChannel::Stable => Version::new(1, 4, 0),
ReleaseChannel::Dev | ReleaseChannel::Nightly | ReleaseChannel::Preview => {
Version::new(0, 232, 0)
Version::new(1, 4, 0)
}
};
if *version >= version_with_parallel_agents
&& !ParallelAgentAnnouncement::dismissed(cx)
&& !project::DisableAiSettings::get_global(cx).disable_ai
{
let fs = <dyn Fs>::global(cx);
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<SharedString> = Vec::with_capacity(3);
bullet_items
.push(format!("Skills live in {GLOBAL_SKILLS_DIR_DISPLAY}/<name>/SKILL.md").into());
if migrated_anything {
bullet_items.push(
"Default Rules are converted into your global AGENTS.md; all other rules become skills".into(),
);
}
bullet_items.push("Type / to manually invoke a skill".into());
Some(AnnouncementContent {
heading: "Introducing Parallel Agents".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 Agentic Layout".into(),
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| {
let get_layout = AgentSettings::get_layout(cx);
let already_agent_layout = matches!(get_layout, WindowLayout::Agent(_));
let update;
if !already_agent_layout {
update = Some(AgentSettings::set_layout(
WindowLayout::Agent(None),
fs.clone(),
cx,
));
} else {
update = None;
}
let revert_fs = fs.clone();
window
.spawn(cx, async move |cx| {
if let Some(update) = update {
update.await.ok();
}
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);
})
})
.detach();
window.dispatch_action(Box::new(zed_actions::assistant::FocusAgent), cx);
})),
on_dismiss: Some(Arc::new(|cx| {
ParallelAgentAnnouncement::set_dismissed(true, cx)
})),
secondary_action_url: Some("https://zed.dev/blog/".into()),
on_dismiss: Some(Arc::new(|cx| SkillsAnnouncement::set_dismissed(true, cx))),
secondary_action_url: Some(zed_urls::skills_docs(cx).into()),
})
} else {
None
@ -323,7 +276,7 @@ impl Notification for AnnouncementToastNotification {}
impl Render for AnnouncementToastNotification {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
AnnouncementToast::new()
.illustration(ParallelAgentsIllustration::new())
.illustration(SkillsIllustration::new())
.heading(self.content.heading.clone())
.description(self.content.description.clone())
.bullet_items(
@ -333,11 +286,12 @@ impl Render for AnnouncementToastNotification {
.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!("Parallel Agent Announcement Main Click");
telemetry::event!("Skills Announcement Main Click");
if let Some(callback) = &callback {
callback(window, cx);
}
@ -350,14 +304,14 @@ impl Render for AnnouncementToastNotification {
.secondary_on_click(cx.listener({
let url = self.content.secondary_action_url.clone();
move |_, _, _window, cx| {
telemetry::event!("Parallel Agent Announcement Secondary Click");
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!("Parallel Agent Announcement Dismiss");
telemetry::event!("Skills Announcement Dismiss");
this.dismiss(cx);
}))
}

View file

@ -52,6 +52,10 @@ pub fn edit_prediction_docs(cx: &App) -> String {
)
}
pub fn skills_docs(cx: &App) -> String {
format!("{server_url}/docs/ai/skills", server_url = server_url(cx))
}
/// Returns the URL to Zed's ACP registry blog post.
pub fn acp_registry_blog(cx: &App) -> String {
format!(
@ -60,11 +64,6 @@ pub fn acp_registry_blog(cx: &App) -> String {
)
}
/// Returns the URL to Zed's Parallel Agents blog post.
pub fn parallel_agents_blog(cx: &App) -> String {
format!("{server_url}/blog", server_url = server_url(cx))
}
pub fn shared_agent_thread_url(session_id: &str) -> String {
format!("zed://agent/shared/{}", session_id)
}

View file

@ -57,15 +57,15 @@ pub const MIGRATION_DONE_KEY: &str = "rules_to_skills_migration_done";
/// Global KVP key for the JSON-serialized [`MigrationResult`] produced by
/// the most recent migration run — the lists of source-Rule titles that
/// were migrated to each destination. The title-bar banner and its
/// explainer modal read this to decide what (if anything) to tell the
/// user about what changed.
/// were migrated to each destination. The skills announcement toast
/// reads this to decide whether to mention the migration in its copy.
pub const MIGRATION_RESULT_KEY: &str = "rules_to_skills_migration_result";
/// A persistent record of what the rules-to-skills migration actually
/// migrated. Persisted in [`GlobalKeyValueStore`] under
/// [`MIGRATION_RESULT_KEY`] and read back by the announcement UI so the
/// modal can list specific rule names instead of vaguely gesturing.
/// [`MIGRATION_RESULT_KEY`] and read back by the skills announcement
/// toast so it can tailor its copy to users who actually had Rules to
/// migrate.
///
/// All three lists hold the *original* user-facing Rule titles, not the
/// derived skill slug or any other transformed identifier — those are
@ -87,10 +87,9 @@ pub struct MigrationResult {
impl MigrationResult {
/// `true` if the migration didn't actually move any Rule anywhere —
/// i.e. the user had no Rules of any kind to migrate. The
/// announcement banner/modal uses this to switch between the
/// "Introducing: Skills" generic intro and the "Skills have replaced
/// Rules" migration summary.
/// i.e. the user had no Rules of any kind to migrate. The skills
/// announcement toast uses this to omit the migration-flavored
/// bullet for users who never had any Rules.
pub fn is_empty(&self) -> bool {
self.skill_names.is_empty()
&& self.agents_md_names.is_empty()

View file

@ -454,21 +454,7 @@ impl TitleBar {
titlebar
});
// The banner label stays static ("Introducing: Skills") regardless
// of whether the user had Rules to migrate; the explainer modal
// is where the migration-specific summary surfaces. Keeping the
// label static avoids the rebuild-on-migration-completion plumbing
// we'd otherwise need to dodge the title-bar-vs-migration race.
let banner = Some(cx.new(|cx| {
OnboardingBanner::new(
"Skills Migration Announcement",
IconName::Sparkle,
"Skills",
Some("Introducing:".into()),
zed_actions::agent::OpenRulesToSkillsMigrationInfo.boxed_clone(),
cx,
)
}));
let banner = None;
let mut this = Self {
platform_titlebar,

View file

@ -1,11 +1,11 @@
mod agent_setup_button;
mod ai_setting_item;
mod configured_api_card;
mod parallel_agents_illustration;
mod skills_illustration;
mod thread_item;
pub use agent_setup_button::*;
pub use ai_setting_item::*;
pub use configured_api_card::*;
pub use parallel_agents_illustration::*;
pub use skills_illustration::*;
pub use thread_item::*;

View file

@ -1,272 +0,0 @@
use crate::{DiffStat, Divider, prelude::*};
use gpui::{Animation, AnimationExt, pulsating_between};
use std::time::Duration;
#[derive(IntoElement)]
pub struct ParallelAgentsIllustration;
impl ParallelAgentsIllustration {
pub fn new() -> Self {
Self
}
}
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 loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| {
div()
.h(rems_from_px(5.))
.w(width)
.rounded_full()
.bg(cx.theme().colors().element_selected)
.with_animation(
id,
Animation::new(Duration::from_millis(duration_ms))
.repeat()
.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_0p5()
.child(
Icon::new(IconName::GitWorktree)
.color(Color::Muted)
.size(IconSize::Indicator),
)
.child(
Label::new(worktree)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
};
let dot_separator = || {
Label::new("")
.size(LabelSize::Small)
.color(Color::Muted)
.alpha(0.5)
};
let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec<AnyElement>| {
v_flex()
.when(selected, |this| {
this.bg(cx.theme().colors().element_active.opacity(0.2))
})
.p_1()
.child(
h_flex()
.w_full()
.gap_1()
.child(
icon_container()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
)
.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_1()
.child(icon_container())
.children(data),
)
};
let agents = v_flex()
.col_span(3)
.bg(cx.theme().colors().elevated_surface_background)
.child(agent(
"Fix branch label".into(),
IconName::ZedAgent,
true,
vec![
worktree("bug-fix".into()).into_any_element(),
dot_separator().into_any_element(),
DiffStat::new("ds", 5, 2)
.label_size(LabelSize::XSmall)
.into_any_element(),
dot_separator().into_any_element(),
time("2m".into()).into_any_element(),
],
))
.child(Divider::horizontal())
.child(agent(
"Improve thread id".into(),
IconName::AiClaude,
false,
vec![
DiffStat::new("ds", 120, 84)
.label_size(LabelSize::XSmall)
.into_any_element(),
dot_separator().into_any_element(),
time("16m".into()).into_any_element(),
],
))
.child(Divider::horizontal())
.child(agent(
"Refactor archive view".into(),
IconName::AiOpenAi,
false,
vec![
worktree("silent-forest".into()).into_any_element(),
dot_separator().into_any_element(),
time("37m".into()).into_any_element(),
],
));
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.opacity(0.6))
.justify_center()
.items_end()
.rounded_t_md()
.overflow_hidden()
.bg(gpui::black().opacity(0.2))
.child(workspace)
}
}

View file

@ -0,0 +1,86 @@
use crate::prelude::*;
use gpui::{linear_color_stop, linear_gradient};
#[derive(IntoElement)]
pub struct SkillsIllustration;
impl SkillsIllustration {
pub fn new() -> Self {
Self
}
}
impl RenderOnce for SkillsIllustration {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let skill_crease = |label: SharedString, source: SharedString| {
h_flex()
.py_1()
.px_1p5()
.gap_1p5()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().element_active.opacity(0.5))
.justify_center()
.rounded_md()
.shadow_sm()
.child(
Icon::new(IconName::Sparkle)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.child(Label::new(label).size(LabelSize::XSmall).buffer_font(cx))
.child(
Label::new(format!("({source})"))
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx),
)
};
let skill_list = v_flex()
.absolute()
.top_8()
.gap_2p5()
.items_center()
.child(
h_flex()
.gap_2p5()
.child(skill_crease("img-gen".into(), "studio".into()))
.child(skill_crease("frontend-design".into(), "global".into())),
)
.child(
h_flex()
.gap_2p5()
.child(skill_crease("brainstorming".into(), "global".into()))
.child(skill_crease("borrow-checker-expert".into(), "zed".into())),
)
.child(
h_flex()
.gap_2p5()
.child(skill_crease("grill-with-docs".into(), "global".into()))
.child(skill_crease("video-edit".into(), "studio".into())),
);
let gradient_bg = cx.theme().colors().editor_background;
let gradient_fade = div()
.absolute()
.rounded_t_md()
.inset_0()
.bg(linear_gradient(
0.,
linear_color_stop(gradient_bg.opacity(0.8), 0.),
linear_color_stop(gradient_bg.opacity(0.0), 1.),
));
v_flex()
.relative()
.h(rems_from_px(150.))
.justify_end()
.items_center()
.rounded_t_md()
.overflow_hidden()
.bg(gpui::black().opacity(0.2))
.child(skill_list)
.child(gradient_fade)
}
}

View file

@ -514,10 +514,6 @@ pub mod agent {
ResetAgentZoom,
/// Pastes clipboard content without any formatting.
PasteRaw,
/// Opens the "Skills have replaced Rules" explainer modal,
/// describing the one-time migration of non-Default Rules to
/// global Skills. Dispatched from the title-bar banner.
OpenRulesToSkillsMigrationInfo,
]
);