Skip to repository content179 lines · 6.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:02:50.773Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
onboarding_banner.rs
1// This module provides infrastructure for showing onboarding banners in the title bar.
2// Currently used by the "Skills have replaced Rules" announcement; older usages
3// (Claude Agent, ACP) lived here previously and were removed.
4#![allow(dead_code)]
5
6use gpui::{Action, Entity, Global, Render, SharedString, TaskExt};
7use ui::{ButtonLike, Tooltip, prelude::*};
8use util::ResultExt;
9
10/// Prompts the user to try newly released Omega features
11pub struct OnboardingBanner {
12 dismissed: bool,
13 source: String,
14 details: BannerDetails,
15 visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
16}
17
18#[derive(Clone)]
19struct BannerGlobal {
20 entity: Entity<OnboardingBanner>,
21}
22impl Global for BannerGlobal {}
23
24pub struct BannerDetails {
25 pub action: Box<dyn Action>,
26 pub icon_name: IconName,
27 pub label: SharedString,
28 pub subtitle: Option<SharedString>,
29}
30
31impl OnboardingBanner {
32 pub fn new(
33 source: &str,
34 icon_name: IconName,
35 label: impl Into<SharedString>,
36 subtitle: Option<SharedString>,
37 action: Box<dyn Action>,
38 cx: &mut Context<Self>,
39 ) -> Self {
40 cx.set_global(BannerGlobal {
41 entity: cx.entity(),
42 });
43 Self {
44 source: source.to_string(),
45 details: BannerDetails {
46 action,
47 icon_name,
48 label: label.into(),
49 subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
50 },
51 visible_when: None,
52 dismissed: get_dismissed(source, cx),
53 }
54 }
55
56 pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
57 self.visible_when = Some(Box::new(predicate));
58 self
59 }
60
61 fn should_show(&self, cx: &mut App) -> bool {
62 !self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
63 }
64
65 fn dismiss(&mut self, cx: &mut Context<Self>) {
66 persist_dismissed(&self.source, cx);
67 self.dismissed = true;
68 cx.notify();
69 }
70}
71
72fn dismissed_at_key(source: &str) -> String {
73 if source == "Git Onboarding" {
74 "zed_git_banner_dismissed_at".to_string()
75 } else {
76 format!(
77 "{}_banner_dismissed_at",
78 source.to_lowercase().trim().replace(" ", "_")
79 )
80 }
81}
82
83fn get_dismissed(source: &str, cx: &App) -> bool {
84 let dismissed_at = dismissed_at_key(source);
85 db::kvp::KeyValueStore::global(cx)
86 .read_kvp(&dismissed_at)
87 .log_err()
88 .is_some_and(|dismissed| dismissed.is_some())
89}
90
91fn persist_dismissed(source: &str, cx: &mut App) {
92 let dismissed_at = dismissed_at_key(source);
93 let kvp = db::kvp::KeyValueStore::global(cx);
94 cx.spawn(async move |_| {
95 let time = chrono::Utc::now().to_rfc3339();
96 kvp.write_kvp(dismissed_at, time).await
97 })
98 .detach_and_log_err(cx);
99}
100
101pub fn restore_banner(cx: &mut App) {
102 if let Some(banner_global) = cx.try_global::<BannerGlobal>() {
103 let entity = banner_global.entity.clone();
104 cx.defer(move |cx| {
105 entity.update(cx, |this, cx| {
106 this.dismissed = false;
107 cx.notify();
108 });
109 });
110
111 let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
112 let dismissed_at = dismissed_at_key(source);
113 let kvp = db::kvp::KeyValueStore::global(cx);
114 cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
115 .detach_and_log_err(cx);
116 }
117}
118
119impl Render for OnboardingBanner {
120 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
121 if !self.should_show(cx) {
122 return div();
123 }
124
125 let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
126 let banner = h_flex()
127 .rounded_sm()
128 .border_1()
129 .border_color(border_color)
130 .occlude()
131 .child(
132 ButtonLike::new("try-a-feature")
133 .child(
134 h_flex()
135 .h_full()
136 .gap_1()
137 .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
138 .child(
139 h_flex()
140 .gap_0p5()
141 .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
142 this.child(
143 Label::new(subtitle)
144 .size(LabelSize::Small)
145 .color(Color::Muted),
146 )
147 })
148 .child(Label::new(&self.details.label).size(LabelSize::Small)),
149 ),
150 )
151 .on_click(cx.listener(|this, _, window, cx| {
152 telemetry::event!("Banner Clicked", source = this.source);
153 this.dismiss(cx);
154 window.dispatch_action(this.details.action.boxed_clone(), cx)
155 })),
156 )
157 .child(
158 div().border_l_1().border_color(border_color).child(
159 IconButton::new("close", IconName::Close)
160 .icon_size(IconSize::Indicator)
161 .on_click(cx.listener(|this, _, _window, cx| {
162 telemetry::event!("Banner Dismissed", source = this.source);
163 this.dismiss(cx)
164 }))
165 .tooltip(|_window, cx| {
166 Tooltip::with_meta(
167 "Close Announcement Banner",
168 None,
169 "It won't show again for this feature",
170 cx,
171 )
172 }),
173 ),
174 );
175
176 div().pr_2().child(banner)
177 }
178}
179