Refine What's New update gating

Show the What's New modal once per newer version by default, add a browser-local permanent opt-out checkbox, and expose the modal through the builtin plugin Open action. Keep the legacy modal path as a compatibility redirect and update the local DOX/tests for the new contract.

Alessandro committed Jun 23, 2026 at 13:14 UTC 82bb0b929268e8ff43b99a8b64c4c1d7e7e8d55f
6 files changed +480 -298
plugins/_whats_new/AGENTS.md
+12 -6
@@ -2,31 +2,37 @@
2
3 ## Purpose
4
5 -- Own the built-in version-gated "What's New" modal for showcasing new Agent Zero features after updates.
5 +- Own the built-in version-gated "What's New" modal for showcasing Agent Zero features after updates.
6
7 ## Ownership
8
9 - `plugin.yaml` owns metadata and always-enabled status.
10 -- `webui/` owns the modal markup, Alpine store, copy, and showcase media assets.
11 -- `extensions/webui/initFw_end/` owns the startup trigger that opens the modal when the installed version is newer than the locally seen version.
10 +- `webui/main.html` owns the canonical modal opened by startup and the Builtin Plugins `Open` button.
11 +- `webui/whats-new.html` is a compatibility redirect to `webui/main.html`.
12 +- `webui/` owns the Alpine store, copy, and showcase media assets.
13 +- `extensions/webui/initFw_end/` owns the startup trigger that opens the modal once per newer installed version unless the user has permanently opted out.
14
15 ## Local Contracts
16
15 -- Do not add a "Don't show this again" control; dismissal records the current installed version as seen.
17 +- Closing, Skip, or Done records only the current installed version as seen.
18 +- Future updates should auto-open the modal again unless the user checks the modal's permanent opt-out checkbox.
19 +- The permanent opt-out is stored in browser-local state under `a0_whats_new_never_show`.
20 +- Honor the legacy `a0_whats_new_seen_version` browser-local marker as the last seen version.
21 - Keep the modal copy concise, left-aligned, and paired with feature media.
22 - Keep modal actions in the pinned footer using the shared Agent Zero button classes.
18 -- Store the seen-version marker in browser-local state only; do not persist this under `usr/`.
23 +- Store seen-version and opt-out markers in browser-local state only; do not persist this under `usr/`.
24
25 ## Work Guidance
26
27 - Add showcase assets under `webui/assets/` and reference them through `/plugins/_whats_new/webui/assets/...`.
28 - Keep the startup extension idempotent and tolerant of missing or non-comparable version labels.
29 - Prefer release-line comparisons over development commit-distance comparisons so local development builds do not reopen the modal on every commit.
30 +- Preserve `webui/main.html` so the plugin list exposes the standard `Open` action.
31
32 ## Verification
33
34 - Run `pytest tests/test_whats_new_static.py` after changing the modal, trigger, or assets.
29 -- Smoke-test startup display, slide navigation, dismissal, and same-version reload behavior in the WebUI.
35 +- Smoke-test startup display, slide navigation, dismissal, same-version reload behavior, newer-version display behavior, opt-out behavior, and manual Builtin Plugins `Open` behavior in the WebUI.
36
37 ## Child DOX Index
38
plugins/_whats_new/extensions/webui/initFw_end/whats-new.js
+74 -34
@@ -1,19 +1,37 @@
1 import { getModalStack, isModalOpen, openModal } from "/js/modals.js";
2
3 -const MODAL_PATH = "/plugins/_whats_new/webui/whats-new.html";
4 -const STORAGE_KEY = "a0_whats_new_seen_version";
3 +const MODAL_PATH = "/plugins/_whats_new/webui/main.html";
4 +const LEGACY_MODAL_PATH = "/plugins/_whats_new/webui/whats-new.html";
5 +const SEEN_VERSION_STORAGE_KEY = "a0_whats_new_seen_version";
6 +const NEVER_SHOW_STORAGE_KEY = "a0_whats_new_never_show";
7 +const INTERMEDIATE_ONCE_STORAGE_KEY = "a0_whats_new_seen_once";
8 const STARTUP_DELAY_MS = 1200;
9 const RETRY_DELAY_MS = 900;
10 const MAX_BUSY_RETRIES = 20;
11
12 let initialized = false;
13 let busyRetries = 0;
11 -let openedForVersion = null;
14
15 function cleanPath(path = "") {
16 return String(path || "").replace(/^\/+/, "");
17 }
18
19 +function storageValue(key) {
20 + try {
21 + return globalThis.localStorage?.getItem(key) || "";
22 + } catch {
23 + return "";
24 + }
25 +}
26 +
27 +function storageSet(key, value) {
28 + try {
29 + globalThis.localStorage?.setItem(key, value);
30 + } catch {
31 + // localStorage may be unavailable in private or locked-down browser modes.
32 + }
33 +}
34 +
35 function parseVersion(value) {
36 const raw = String(value || "").trim();
37 if (!raw || raw.toLowerCase() === "unknown") return null;
@@ -31,6 +49,16 @@ function parseVersion(value) {
49 };
50 }
51
52 +function parseStoredVersion(rawValue) {
53 + if (!rawValue) return null;
54 + try {
55 + const parsed = JSON.parse(rawValue);
56 + return parseVersion(parsed?.version || parsed?.raw || rawValue);
57 + } catch {
58 + return parseVersion(rawValue);
59 + }
60 +}
61 +
62 function compareVersions(left, right) {
63 if (!left || !right) return null;
64 for (let index = 0; index < left.parts.length; index += 1) {
@@ -45,23 +73,43 @@ function currentVersion() {
73 return parseVersion(globalThis.gitinfo?.version || "");
74 }
75
76 +function markVersionSeen(version = currentVersion()) {
77 + if (!version) return;
78 + storageSet(
79 + SEEN_VERSION_STORAGE_KEY,
80 + JSON.stringify({
81 + version: version.raw,
82 + seenAt: new Date().toISOString(),
83 + }),
84 + );
85 +}
86 +
87 function storedSeenVersion() {
88 + const stored = parseStoredVersion(storageValue(SEEN_VERSION_STORAGE_KEY));
89 + if (stored) return stored;
90 +
91 + const intermediate = parseStoredVersion(storageValue(INTERMEDIATE_ONCE_STORAGE_KEY));
92 + if (!intermediate) return null;
93 +
94 + markVersionSeen(intermediate);
95 + return intermediate;
96 +}
97 +
98 +function shouldNeverShow() {
99 + const value = storageValue(NEVER_SHOW_STORAGE_KEY);
100 + if (!value) return false;
101 +
102 try {
50 - const rawValue = globalThis.localStorage?.getItem(STORAGE_KEY) || "";
51 - if (!rawValue) return null;
52 -
53 - try {
54 - const parsed = JSON.parse(rawValue);
55 - return parseVersion(parsed?.version || parsed?.raw || rawValue);
56 - } catch {
57 - return parseVersion(rawValue);
58 - }
103 + const parsed = JSON.parse(value);
104 + if (parsed && typeof parsed === "object") return parsed.enabled !== false;
105 + return Boolean(parsed);
106 } catch {
60 - return null;
107 + return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
108 }
109 }
110
64 -function shouldShowWhatsNew(version) {
111 +function shouldShowWhatsNew(version = currentVersion()) {
112 + if (shouldNeverShow()) return false;
113 if (!version) return false;
114
115 const seen = storedSeenVersion();
@@ -71,25 +119,19 @@ function shouldShowWhatsNew(version) {
119 return comparison !== null && comparison > 0;
120 }
121
74 -function markVersionSeen(version = currentVersion()) {
75 - if (!version) return;
76 - try {
77 - globalThis.localStorage?.setItem(
78 - STORAGE_KEY,
79 - JSON.stringify({
80 - version: version.raw,
81 - seenAt: new Date().toISOString(),
82 - }),
83 - );
84 - } catch {
85 - // localStorage may be unavailable in private or locked-down browser modes.
86 - }
87 -}
88 -
122 function anotherModalIsOpen() {
123 return getModalStack().length > 0 || Boolean(document.querySelector(".modal.show"));
124 }
125
126 +function isWhatsNewPath(path = "") {
127 + const cleaned = cleanPath(path);
128 + return [MODAL_PATH, LEGACY_MODAL_PATH].some((candidate) => cleanPath(candidate) === cleaned);
129 +}
130 +
131 +function isWhatsNewOpen() {
132 + return isModalOpen(MODAL_PATH) || isModalOpen(LEGACY_MODAL_PATH);
133 +}
134 +
135 function scheduleRetry() {
136 if (busyRetries >= MAX_BUSY_RETRIES) return;
137 busyRetries += 1;
@@ -102,21 +144,19 @@ function maybeOpenWhatsNew() {
144 const version = currentVersion();
145 if (!shouldShowWhatsNew(version)) return;
146
105 - if (isModalOpen(MODAL_PATH)) return;
147 + if (isWhatsNewOpen()) return;
148 if (anotherModalIsOpen()) {
149 scheduleRetry();
150 return;
151 }
152
111 - openedForVersion = version;
153 void openModal(MODAL_PATH);
154 }
155
156 function handleModalClosed(event) {
157 const closedPath = event?.detail?.modalPath || "";
117 - if (cleanPath(closedPath) === cleanPath(MODAL_PATH)) {
118 - markVersionSeen(openedForVersion || currentVersion());
119 - openedForVersion = null;
158 + if (isWhatsNewPath(closedPath)) {
159 + markVersionSeen();
160 return;
161 }
162
plugins/_whats_new/webui/main.html new
+308
@@ -0,0 +1,308 @@
1 +<html class="whats-new-modal">
2 +<head>
3 + <title>What's New in Agent Zero</title>
4 + <script type="module">
5 + import { store } from "/plugins/_whats_new/webui/whats-new-store.js";
6 + </script>
7 +</head>
8 +
9 +<body>
10 + <div x-data>
11 + <template x-if="$store.whatsNew">
12 + <div class="whats-new-shell" x-init="$store.whatsNew.onOpen()" x-destroy="$store.whatsNew.cleanup()">
13 + <section class="whats-new-media-panel" :aria-label="$store.whatsNew.currentSlide.mediaLabel">
14 + <template x-if="$store.whatsNew.currentSlide.mediaType === 'video'">
15 + <video
16 + class="whats-new-media"
17 + :src="$store.whatsNew.currentSlide.media"
18 + :aria-label="$store.whatsNew.currentSlide.mediaLabel"
19 + autoplay
20 + loop
21 + muted
22 + playsinline
23 + ></video>
24 + </template>
25 + <template x-if="$store.whatsNew.currentSlide.mediaType === 'image'">
26 + <img
27 + class="whats-new-media"
28 + :src="$store.whatsNew.currentSlide.media"
29 + :alt="$store.whatsNew.currentSlide.mediaLabel"
30 + />
31 + </template>
32 + </section>
33 +
34 + <section class="whats-new-copy">
35 + <div class="whats-new-eyebrow" x-text="$store.whatsNew.currentSlide.eyebrow"></div>
36 + <h3 x-text="$store.whatsNew.currentSlide.title"></h3>
37 + <p class="whats-new-summary" x-text="$store.whatsNew.currentSlide.summary"></p>
38 + <ul class="whats-new-bullets">
39 + <template x-for="item in $store.whatsNew.currentSlide.bullets" :key="item">
40 + <li x-text="item"></li>
41 + </template>
42 + </ul>
43 + </section>
44 +
45 + <div class="modal-footer whats-new-footer" data-modal-footer>
46 + <div class="whats-new-footer-left">
47 + <div class="whats-new-footer-progress">
48 + <span class="whats-new-progress-label" x-text="$store.whatsNew.progressLabel()"></span>
49 + <div class="whats-new-progress-dots" role="tablist" aria-label="What's New slides">
50 + <template x-for="(slide, index) in $store.whatsNew.slides" :key="slide.id">
51 + <button
52 + type="button"
53 + class="whats-new-dot"
54 + role="tab"
55 + :class="{ 'active': index === $store.whatsNew.currentIndex }"
56 + :aria-label="$store.whatsNew.dotLabel(index)"
57 + :aria-selected="index === $store.whatsNew.currentIndex ? 'true' : 'false'"
58 + @click="$store.whatsNew.goTo(index)"
59 + ></button>
60 + </template>
61 + </div>
62 + </div>
63 + <label class="whats-new-never-show">
64 + <input
65 + type="checkbox"
66 + :checked="$store.whatsNew.neverShowAgain"
67 + @change="$store.whatsNew.setNeverShowAgain($event.target.checked)"
68 + />
69 + <span>Don't show automatically again</span>
70 + </label>
71 + </div>
72 + <div class="whats-new-footer-actions">
73 + <button type="button" class="btn btn-cancel" @click="$store.whatsNew.skip()">Skip</button>
74 + <button
75 + type="button"
76 + class="btn btn-field"
77 + x-show="!$store.whatsNew.isFirst()"
78 + @click="$store.whatsNew.previous()"
79 + >
80 + Back
81 + </button>
82 + <button type="button" class="btn btn-ok" @click="$store.whatsNew.next()">
83 + <span x-text="$store.whatsNew.isLast() ? 'Done' : 'Next'"></span>
84 + </button>
85 + </div>
86 + </div>
87 + </div>
88 + </template>
89 + </div>
90 +
91 + <style>
92 + .modal-inner.whats-new-modal {
93 + width: min(92vw, 760px);
94 + max-height: min(88vh, 820px);
95 + }
96 +
97 + .whats-new-shell {
98 + display: flex;
99 + flex-direction: column;
100 + gap: 1rem;
101 + min-width: 0;
102 + }
103 +
104 + .whats-new-media-panel {
105 + position: relative;
106 + display: flex;
107 + align-items: center;
108 + justify-content: center;
109 + width: 100%;
110 + aspect-ratio: 1476 / 842;
111 + min-height: 180px;
112 + max-height: min(44vh, 360px);
113 + overflow: hidden;
114 + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
115 + border-radius: 7px;
116 + background:
117 + radial-gradient(circle at 18% 12%, color-mix(in srgb, #4248f1 18%, transparent), transparent 34%),
118 + color-mix(in srgb, var(--color-panel) 78%, #000 22%);
119 + }
120 +
121 + .whats-new-media {
122 + display: block;
123 + width: 100%;
124 + height: 100%;
125 + object-fit: cover;
126 + background: color-mix(in srgb, var(--color-panel) 82%, #000 18%);
127 + }
128 +
129 + .whats-new-copy {
130 + display: flex;
131 + flex-direction: column;
132 + gap: 0.55rem;
133 + min-width: 0;
134 + color: var(--color-text);
135 + text-align: left;
136 + }
137 +
138 + .whats-new-eyebrow {
139 + color: color-mix(in srgb, #2196f3 86%, var(--color-text));
140 + font-size: 0.74rem;
141 + font-weight: 760;
142 + letter-spacing: 0.04em;
143 + text-transform: uppercase;
144 + }
145 +
146 + .whats-new-copy h3 {
147 + margin: 0;
148 + color: var(--color-text);
149 + font-size: clamp(1.35rem, 2.5vw, 1.8rem);
150 + font-weight: 760;
151 + line-height: 1.12;
152 + letter-spacing: 0;
153 + }
154 +
155 + .whats-new-summary {
156 + max-width: 62ch;
157 + margin: 0;
158 + color: var(--color-text-muted);
159 + font-size: 0.98rem;
160 + line-height: 1.45;
161 + }
162 +
163 + .whats-new-bullets {
164 + display: grid;
165 + gap: 0.45rem;
166 + margin: 0.25rem 0 0;
167 + padding: 0;
168 + list-style: none;
169 + }
170 +
171 + .whats-new-bullets li {
172 + position: relative;
173 + min-width: 0;
174 + padding-left: 1.1rem;
175 + color: var(--color-text-muted);
176 + font-size: 0.9rem;
177 + line-height: 1.42;
178 + }
179 +
180 + .whats-new-bullets li::before {
181 + content: "";
182 + position: absolute;
183 + left: 0;
184 + top: 0.58em;
185 + width: 0.42rem;
186 + height: 0.42rem;
187 + border-radius: 999px;
188 + background: color-mix(in srgb, #4248f1 72%, #2196f3 28%);
189 + box-shadow: 0 0 0 3px color-mix(in srgb, #4248f1 18%, transparent);
190 + }
191 +
192 + .whats-new-footer {
193 + justify-content: space-between;
194 + gap: 1rem;
195 + }
196 +
197 + .whats-new-footer-left,
198 + .whats-new-footer-actions {
199 + display: flex;
200 + align-items: center;
201 + gap: 0.65rem;
202 + min-width: 0;
203 + }
204 +
205 + .whats-new-footer-left {
206 + flex-wrap: wrap;
207 + }
208 +
209 + .whats-new-footer-progress {
210 + display: flex;
211 + align-items: center;
212 + gap: 0.65rem;
213 + min-width: 0;
214 + }
215 +
216 + .whats-new-progress-label {
217 + color: var(--color-text-muted);
218 + font-size: 0.78rem;
219 + white-space: nowrap;
220 + }
221 +
222 + .whats-new-progress-dots {
223 + display: flex;
224 + align-items: center;
225 + gap: 0.35rem;
226 + }
227 +
228 + .whats-new-dot {
229 + width: 0.48rem;
230 + height: 0.48rem;
231 + padding: 0;
232 + border: 0;
233 + border-radius: 999px;
234 + background: color-mix(in srgb, var(--color-text-muted) 45%, transparent);
235 + cursor: pointer;
236 + transition: transform 0.16s ease, background-color 0.16s ease, width 0.16s ease;
237 + }
238 +
239 + .whats-new-dot.active {
240 + width: 1.2rem;
241 + background: color-mix(in srgb, #4248f1 74%, #2196f3 26%);
242 + }
243 +
244 + .whats-new-dot:hover {
245 + transform: translateY(-1px);
246 + background: color-mix(in srgb, #2196f3 72%, var(--color-text));
247 + }
248 +
249 + .whats-new-never-show {
250 + display: inline-flex;
251 + align-items: center;
252 + gap: 0.35rem;
253 + min-width: 0;
254 + color: var(--color-text-muted);
255 + font-size: 0.78rem;
256 + cursor: pointer;
257 + user-select: none;
258 + }
259 +
260 + .whats-new-never-show input {
261 + width: 0.95rem;
262 + height: 0.95rem;
263 + margin: 0;
264 + flex: 0 0 auto;
265 + accent-color: #4248f1;
266 + }
267 +
268 + .whats-new-never-show span {
269 + min-width: 0;
270 + white-space: normal;
271 + }
272 +
273 + @media (max-width: 640px) {
274 + .modal-inner.whats-new-modal {
275 + width: min(94vw, 760px);
276 + }
277 +
278 + .whats-new-media-panel {
279 + min-height: 150px;
280 + max-height: 32vh;
281 + }
282 +
283 + .whats-new-footer,
284 + .whats-new-footer-left,
285 + .whats-new-footer-actions {
286 + align-items: stretch;
287 + }
288 +
289 + .whats-new-footer {
290 + flex-direction: column;
291 + }
292 +
293 + .whats-new-footer-left {
294 + justify-content: space-between;
295 + }
296 +
297 + .whats-new-footer-progress {
298 + justify-content: space-between;
299 + }
300 +
301 + .whats-new-footer-actions {
302 + justify-content: flex-end;
303 + flex-wrap: wrap;
304 + }
305 + }
306 + </style>
307 +</body>
308 +</html>
plugins/_whats_new/webui/whats-new-store.js
+50 -1
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import { closeModal } from "/js/modals.js";
3
4 const ASSET_BASE = "/plugins/_whats_new/webui/assets";
5 +const NEVER_SHOW_STORAGE_KEY = "a0_whats_new_never_show";
6
7 const slides = [
8 {
@@ -54,15 +55,58 @@ const slides = [
55 },
56 ];
57
58 +function storageValue(key) {
59 + try {
60 + return globalThis.localStorage?.getItem(key) || "";
61 + } catch {
62 + return "";
63 + }
64 +}
65 +
66 +function isNeverShowEnabled() {
67 + const value = storageValue(NEVER_SHOW_STORAGE_KEY);
68 + if (!value) return false;
69 +
70 + try {
71 + const parsed = JSON.parse(value);
72 + if (parsed && typeof parsed === "object") return parsed.enabled !== false;
73 + return Boolean(parsed);
74 + } catch {
75 + return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
76 + }
77 +}
78 +
79 +function persistNeverShowPreference(enabled) {
80 + try {
81 + if (enabled) {
82 + globalThis.localStorage?.setItem(
83 + NEVER_SHOW_STORAGE_KEY,
84 + JSON.stringify({
85 + enabled: true,
86 + updatedAt: new Date().toISOString(),
87 + }),
88 + );
89 + } else {
90 + globalThis.localStorage?.removeItem(NEVER_SHOW_STORAGE_KEY);
91 + }
92 + } catch {
93 + // localStorage may be unavailable in private or locked-down browser modes.
94 + }
95 +}
96 +
97 export const store = createStore("whatsNew", {
98 slides,
99 currentIndex: 0,
100 + neverShowAgain: false,
101
102 onOpen() {
103 this.currentIndex = 0;
104 + this.neverShowAgain = isNeverShowEnabled();
105 },
106
65 - cleanup() {},
107 + cleanup() {
108 + persistNeverShowPreference(this.neverShowAgain);
109 + },
110
111 get currentSlide() {
112 return this.slides[this.currentIndex] || this.slides[0];
@@ -96,6 +140,11 @@ export const store = createStore("whatsNew", {
140 if (!this.isFirst()) this.currentIndex -= 1;
141 },
142
143 + setNeverShowAgain(value) {
144 + this.neverShowAgain = Boolean(value);
145 + persistNeverShowPreference(this.neverShowAgain);
146 + },
147 +
148 next() {
149 if (this.isLast()) {
150 this.finish();
plugins/_whats_new/webui/whats-new.html
+11 -250
@@ -1,259 +1,20 @@
1 -<html class="whats-new-modal">
1 +<html>
2 <head>
3 <title>What's New in Agent Zero</title>
4 <script type="module">
5 - import { store } from "/plugins/_whats_new/webui/whats-new-store.js";
5 + import { closeModal, openModal } from "/js/modals.js";
6 +
7 + const legacyPath = "/plugins/_whats_new/webui/whats-new.html";
8 + const mainPath = "/plugins/_whats_new/webui/main.html";
9 +
10 + queueMicrotask(async () => {
11 + await closeModal(legacyPath);
12 + await openModal(mainPath);
13 + });
14 </script>
15 </head>
16
17 <body>
10 - <div x-data>
11 - <template x-if="$store.whatsNew">
12 - <div class="whats-new-shell" x-init="$store.whatsNew.onOpen()" x-destroy="$store.whatsNew.cleanup()">
13 - <section class="whats-new-media-panel" :aria-label="$store.whatsNew.currentSlide.mediaLabel">
14 - <template x-if="$store.whatsNew.currentSlide.mediaType === 'video'">
15 - <video
16 - class="whats-new-media"
17 - :src="$store.whatsNew.currentSlide.media"
18 - :aria-label="$store.whatsNew.currentSlide.mediaLabel"
19 - autoplay
20 - loop
21 - muted
22 - playsinline
23 - ></video>
24 - </template>
25 - <template x-if="$store.whatsNew.currentSlide.mediaType === 'image'">
26 - <img
27 - class="whats-new-media"
28 - :src="$store.whatsNew.currentSlide.media"
29 - :alt="$store.whatsNew.currentSlide.mediaLabel"
30 - />
31 - </template>
32 - </section>
33 -
34 - <section class="whats-new-copy">
35 - <div class="whats-new-eyebrow" x-text="$store.whatsNew.currentSlide.eyebrow"></div>
36 - <h3 x-text="$store.whatsNew.currentSlide.title"></h3>
37 - <p class="whats-new-summary" x-text="$store.whatsNew.currentSlide.summary"></p>
38 - <ul class="whats-new-bullets">
39 - <template x-for="item in $store.whatsNew.currentSlide.bullets" :key="item">
40 - <li x-text="item"></li>
41 - </template>
42 - </ul>
43 - </section>
44 -
45 - <div class="modal-footer whats-new-footer" data-modal-footer>
46 - <div class="whats-new-footer-left">
47 - <span class="whats-new-progress-label" x-text="$store.whatsNew.progressLabel()"></span>
48 - <div class="whats-new-progress-dots" role="tablist" aria-label="What's New slides">
49 - <template x-for="(slide, index) in $store.whatsNew.slides" :key="slide.id">
50 - <button
51 - type="button"
52 - class="whats-new-dot"
53 - role="tab"
54 - :class="{ 'active': index === $store.whatsNew.currentIndex }"
55 - :aria-label="$store.whatsNew.dotLabel(index)"
56 - :aria-selected="index === $store.whatsNew.currentIndex ? 'true' : 'false'"
57 - @click="$store.whatsNew.goTo(index)"
58 - ></button>
59 - </template>
60 - </div>
61 - </div>
62 - <div class="whats-new-footer-actions">
63 - <button type="button" class="btn btn-cancel" @click="$store.whatsNew.skip()">Skip</button>
64 - <button
65 - type="button"
66 - class="btn btn-field"
67 - x-show="!$store.whatsNew.isFirst()"
68 - @click="$store.whatsNew.previous()"
69 - >
70 - Back
71 - </button>
72 - <button type="button" class="btn btn-ok" @click="$store.whatsNew.next()">
73 - <span x-text="$store.whatsNew.isLast() ? 'Done' : 'Next'"></span>
74 - </button>
75 - </div>
76 - </div>
77 - </div>
78 - </template>
79 - </div>
80 -
81 - <style>
82 - .modal-inner.whats-new-modal {
83 - width: min(92vw, 760px);
84 - max-height: min(88vh, 820px);
85 - }
86 -
87 - .whats-new-shell {
88 - display: flex;
89 - flex-direction: column;
90 - gap: 1rem;
91 - min-width: 0;
92 - }
93 -
94 - .whats-new-media-panel {
95 - position: relative;
96 - display: flex;
97 - align-items: center;
98 - justify-content: center;
99 - width: 100%;
100 - aspect-ratio: 1476 / 842;
101 - min-height: 180px;
102 - max-height: min(44vh, 360px);
103 - overflow: hidden;
104 - border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
105 - border-radius: 7px;
106 - background:
107 - radial-gradient(circle at 18% 12%, color-mix(in srgb, #4248f1 18%, transparent), transparent 34%),
108 - color-mix(in srgb, var(--color-panel) 78%, #000 22%);
109 - }
110 -
111 - .whats-new-media {
112 - display: block;
113 - width: 100%;
114 - height: 100%;
115 - object-fit: cover;
116 - background: color-mix(in srgb, var(--color-panel) 82%, #000 18%);
117 - }
118 -
119 - .whats-new-copy {
120 - display: flex;
121 - flex-direction: column;
122 - gap: 0.55rem;
123 - min-width: 0;
124 - color: var(--color-text);
125 - text-align: left;
126 - }
127 -
128 - .whats-new-eyebrow {
129 - color: color-mix(in srgb, #2196f3 86%, var(--color-text));
130 - font-size: 0.74rem;
131 - font-weight: 760;
132 - letter-spacing: 0.04em;
133 - text-transform: uppercase;
134 - }
135 -
136 - .whats-new-copy h3 {
137 - margin: 0;
138 - color: var(--color-text);
139 - font-size: clamp(1.35rem, 2.5vw, 1.8rem);
140 - font-weight: 760;
141 - line-height: 1.12;
142 - letter-spacing: 0;
143 - }
144 -
145 - .whats-new-summary {
146 - max-width: 62ch;
147 - margin: 0;
148 - color: var(--color-text-muted);
149 - font-size: 0.98rem;
150 - line-height: 1.45;
151 - }
152 -
153 - .whats-new-bullets {
154 - display: grid;
155 - gap: 0.45rem;
156 - margin: 0.25rem 0 0;
157 - padding: 0;
158 - list-style: none;
159 - }
160 -
161 - .whats-new-bullets li {
162 - position: relative;
163 - min-width: 0;
164 - padding-left: 1.1rem;
165 - color: var(--color-text-muted);
166 - font-size: 0.9rem;
167 - line-height: 1.42;
168 - }
169 -
170 - .whats-new-bullets li::before {
171 - content: "";
172 - position: absolute;
173 - left: 0;
174 - top: 0.58em;
175 - width: 0.42rem;
176 - height: 0.42rem;
177 - border-radius: 999px;
178 - background: color-mix(in srgb, #4248f1 72%, #2196f3 28%);
179 - box-shadow: 0 0 0 3px color-mix(in srgb, #4248f1 18%, transparent);
180 - }
181 -
182 - .whats-new-footer {
183 - justify-content: space-between;
184 - gap: 1rem;
185 - }
186 -
187 - .whats-new-footer-left,
188 - .whats-new-footer-actions {
189 - display: flex;
190 - align-items: center;
191 - gap: 0.65rem;
192 - min-width: 0;
193 - }
194 -
195 - .whats-new-progress-label {
196 - color: var(--color-text-muted);
197 - font-size: 0.78rem;
198 - white-space: nowrap;
199 - }
200 -
201 - .whats-new-progress-dots {
202 - display: flex;
203 - align-items: center;
204 - gap: 0.35rem;
205 - }
206 -
207 - .whats-new-dot {
208 - width: 0.48rem;
209 - height: 0.48rem;
210 - padding: 0;
211 - border: 0;
212 - border-radius: 999px;
213 - background: color-mix(in srgb, var(--color-text-muted) 45%, transparent);
214 - cursor: pointer;
215 - transition: transform 0.16s ease, background-color 0.16s ease, width 0.16s ease;
216 - }
217 -
218 - .whats-new-dot.active {
219 - width: 1.2rem;
220 - background: color-mix(in srgb, #4248f1 74%, #2196f3 26%);
221 - }
222 -
223 - .whats-new-dot:hover {
224 - transform: translateY(-1px);
225 - background: color-mix(in srgb, #2196f3 72%, var(--color-text));
226 - }
227 -
228 - @media (max-width: 640px) {
229 - .modal-inner.whats-new-modal {
230 - width: min(94vw, 760px);
231 - }
232 -
233 - .whats-new-media-panel {
234 - min-height: 150px;
235 - max-height: 32vh;
236 - }
237 -
238 - .whats-new-footer,
239 - .whats-new-footer-left,
240 - .whats-new-footer-actions {
241 - align-items: stretch;
242 - }
243 -
244 - .whats-new-footer {
245 - flex-direction: column;
246 - }
247 -
248 - .whats-new-footer-left {
249 - justify-content: space-between;
250 - }
251 -
252 - .whats-new-footer-actions {
253 - justify-content: flex-end;
254 - flex-wrap: wrap;
255 - }
256 - }
257 - </style>
18 + <div class="loading">Opening What's New...</div>
19 </body>
20 </html>
tests/test_whats_new_static.py
+25 -7
@@ -2,19 +2,22 @@ from pathlib import Path
2
3
4 PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +WHATS_NEW_PLUGIN = PROJECT_ROOT / "plugins/_whats_new"
6
7
8 def test_whats_new_modal_uses_showcase_assets_and_branded_footer():
8 - html = (PROJECT_ROOT / "plugins/_whats_new/webui/whats-new.html").read_text(encoding="utf-8")
9 - store = (PROJECT_ROOT / "plugins/_whats_new/webui/whats-new-store.js").read_text(encoding="utf-8")
9 + html = (WHATS_NEW_PLUGIN / "webui/main.html").read_text(encoding="utf-8")
10 + store = (WHATS_NEW_PLUGIN / "webui/whats-new-store.js").read_text(encoding="utf-8")
11
12 assert "What's New in Agent Zero" in html
13 assert "data-modal-footer" in html
14 assert "btn btn-ok" in html
15 assert "btn btn-field" in html
16 + assert "type=\"checkbox\"" in html
17 + assert "Don't show automatically again" in html
18 assert "/plugins/_whats_new/webui/whats-new-store.js" in html
19 assert "/plugins/_whats_new/webui/assets" in store
17 - assert "Don't show this again" not in html + store
20 + assert "a0_whats_new_never_show" in store
21
22 for asset in ["parallel-subs.webm", "mcp-servers.png", "skills-scanner.png"]:
23 assert asset in html + store
@@ -34,23 +37,38 @@ def test_whats_new_modal_uses_showcase_assets_and_branded_footer():
37 assert "Include MCP servers in the same pass" not in store
38
39
37 -def test_whats_new_startup_trigger_is_version_gated():
40 +def test_whats_new_legacy_modal_path_redirects_to_main_screen():
41 + html = (WHATS_NEW_PLUGIN / "webui/whats-new.html").read_text(encoding="utf-8")
42 +
43 + assert "/plugins/_whats_new/webui/main.html" in html
44 + assert "openModal(mainPath)" in html
45 + assert "What's New in Agent Zero" in html
46 +
47 +
48 +def test_whats_new_startup_trigger_is_version_gated_with_opt_out():
49 content = (
39 - PROJECT_ROOT / "plugins/_whats_new/extensions/webui/initFw_end/whats-new.js"
50 + WHATS_NEW_PLUGIN / "extensions/webui/initFw_end/whats-new.js"
51 ).read_text(encoding="utf-8")
52
53 assert "globalThis.gitinfo?.version" in content
54 assert "a0_whats_new_seen_version" in content
55 + assert "a0_whats_new_never_show" in content
56 + assert "/plugins/_whats_new/webui/main.html" in content
57 assert "/plugins/_whats_new/webui/whats-new.html" in content
58 assert "compareVersions" in content
59 + assert "storedSeenVersion" in content
60 assert "shouldShowWhatsNew" in content
61 + assert "shouldNeverShow" in content
62 assert "modal-closed" in content
63 assert "markVersionSeen" in content
49 - assert "Don't show this again" not in content
64 +
65 +
66 +def test_whats_new_exposes_builtin_plugin_open_screen():
67 + assert (WHATS_NEW_PLUGIN / "webui/main.html").exists()
68
69
70 def test_whats_new_plugin_manifest_is_always_enabled():
53 - manifest = (PROJECT_ROOT / "plugins/_whats_new/plugin.yaml").read_text(encoding="utf-8")
71 + manifest = (WHATS_NEW_PLUGIN / "plugin.yaml").read_text(encoding="utf-8")
72
73 assert "name: _whats_new" in manifest
74 assert "always_enabled: true" in manifest