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