Add built-in What's New showcase plugin
Introduce an always-enabled _whats_new plugin that shows a version-gated feature showcase modal with bundled media assets for parallel tool calls, the redesigned MCP configuration UI, and Skills Scanner. Track the plugin in DOX and add static coverage for the modal copy, assets, manifest, and startup trigger.
Alessandro committed
Jun 19, 2026 at 17:26 UTC
2a81e3749953a2b33a879dae850407ae71b7fadc
10 files changed
+609
plugins/AGENTS.md
+1
@@ -93,4 +93,5 @@ Direct child DOX files:
93
| [_text_editor/AGENTS.md](_text_editor/AGENTS.md) | Native text read, write, and patch tool. |
94
| [_time_travel/AGENTS.md](_time_travel/AGENTS.md) | Workspace history, diff, travel, snapshot, and revert flows. |
95
| [_whatsapp_integration/AGENTS.md](_whatsapp_integration/AGENTS.md) | WhatsApp Baileys bridge integration. |
96
+| [_whats_new/AGENTS.md](_whats_new/AGENTS.md) | Version-gated What's New showcase modal and startup trigger. |
97
| [_whisper_stt/AGENTS.md](_whisper_stt/AGENTS.md) | Whisper speech-to-text integration. |
plugins/_whats_new/AGENTS.md
new
+33
@@ -0,0 +1,33 @@
1
+# What's New Plugin DOX
2
+
3
+## Purpose
4
+
5
+- Own the built-in version-gated "What's New" modal for showcasing new 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.
12
+
13
+## Local Contracts
14
+
15
+- Do not add a "Don't show this again" control; dismissal records the current installed version as seen.
16
+- Keep the modal copy concise, left-aligned, and paired with feature media.
17
+- 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/`.
19
+
20
+## Work Guidance
21
+
22
+- Add showcase assets under `webui/assets/` and reference them through `/plugins/_whats_new/webui/assets/...`.
23
+- Keep the startup extension idempotent and tolerant of missing or non-comparable version labels.
24
+- Prefer release-line comparisons over development commit-distance comparisons so local development builds do not reopen the modal on every commit.
25
+
26
+## Verification
27
+
28
+- 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.
30
+
31
+## Child DOX Index
32
+
33
+No child DOX files.
plugins/_whats_new/extensions/webui/initFw_end/whats-new.js
new
+137
@@ -0,0 +1,137 @@
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";
5
+const STARTUP_DELAY_MS = 1200;
6
+const RETRY_DELAY_MS = 900;
7
+const MAX_BUSY_RETRIES = 20;
8
+
9
+let initialized = false;
10
+let busyRetries = 0;
11
+let openedForVersion = null;
12
+
13
+function cleanPath(path = "") {
14
+ return String(path || "").replace(/^\/+/, "");
15
+}
16
+
17
+function parseVersion(value) {
18
+ const raw = String(value || "").trim();
19
+ if (!raw || raw.toLowerCase() === "unknown") return null;
20
+
21
+ const match = /(?:^|[\s(])v?(\d+)\.(\d+)(?:\.(\d+))?(?:\+(\d+))?/.exec(raw);
22
+ if (!match) return null;
23
+
24
+ return {
25
+ raw,
26
+ parts: [
27
+ Number.parseInt(match[1], 10),
28
+ Number.parseInt(match[2], 10),
29
+ Number.parseInt(match[3] || "0", 10),
30
+ ],
31
+ };
32
+}
33
+
34
+function compareVersions(left, right) {
35
+ if (!left || !right) return null;
36
+ for (let index = 0; index < left.parts.length; index += 1) {
37
+ if (left.parts[index] !== right.parts[index]) {
38
+ return left.parts[index] - right.parts[index];
39
+ }
40
+ }
41
+ return 0;
42
+}
43
+
44
+function currentVersion() {
45
+ return parseVersion(globalThis.gitinfo?.version || "");
46
+}
47
+
48
+function storedSeenVersion() {
49
+ 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
+ }
59
+ } catch {
60
+ return null;
61
+ }
62
+}
63
+
64
+function shouldShowWhatsNew(version) {
65
+ if (!version) return false;
66
+
67
+ const seen = storedSeenVersion();
68
+ if (!seen) return true;
69
+
70
+ const comparison = compareVersions(version, seen);
71
+ return comparison !== null && comparison > 0;
72
+}
73
+
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
+
89
+function anotherModalIsOpen() {
90
+ return getModalStack().length > 0 || Boolean(document.querySelector(".modal.show"));
91
+}
92
+
93
+function scheduleRetry() {
94
+ if (busyRetries >= MAX_BUSY_RETRIES) return;
95
+ busyRetries += 1;
96
+ window.setTimeout(() => {
97
+ maybeOpenWhatsNew();
98
+ }, RETRY_DELAY_MS);
99
+}
100
+
101
+function maybeOpenWhatsNew() {
102
+ const version = currentVersion();
103
+ if (!shouldShowWhatsNew(version)) return;
104
+
105
+ if (isModalOpen(MODAL_PATH)) return;
106
+ if (anotherModalIsOpen()) {
107
+ scheduleRetry();
108
+ return;
109
+ }
110
+
111
+ openedForVersion = version;
112
+ void openModal(MODAL_PATH);
113
+}
114
+
115
+function handleModalClosed(event) {
116
+ const closedPath = event?.detail?.modalPath || "";
117
+ if (cleanPath(closedPath) === cleanPath(MODAL_PATH)) {
118
+ markVersionSeen(openedForVersion || currentVersion());
119
+ openedForVersion = null;
120
+ return;
121
+ }
122
+
123
+ window.setTimeout(() => {
124
+ maybeOpenWhatsNew();
125
+ }, RETRY_DELAY_MS);
126
+}
127
+
128
+export default function initWhatsNew() {
129
+ if (initialized) return;
130
+ initialized = true;
131
+
132
+ document.addEventListener("modal-closed", handleModalClosed);
133
+
134
+ window.setTimeout(() => {
135
+ maybeOpenWhatsNew();
136
+ }, STARTUP_DELAY_MS);
137
+}
plugins/_whats_new/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: _whats_new
2
+title: What's New
3
+description: Built-in version-gated showcase modal for new Agent Zero features.
4
+version: 1.0.0
5
+settings_sections: []
6
+always_enabled: true
7
+per_project_config: false
8
+per_agent_config: false
plugins/_whats_new/webui/assets/mcp-servers.png
Binary files /dev/null and b/plugins/_whats_new/webui/assets/mcp-servers.png differ
plugins/_whats_new/webui/assets/parallel-subs.webm
Binary files /dev/null and b/plugins/_whats_new/webui/assets/parallel-subs.webm differ
plugins/_whats_new/webui/assets/skills-scanner.png
Binary files /dev/null and b/plugins/_whats_new/webui/assets/skills-scanner.png differ
plugins/_whats_new/webui/whats-new-store.js
new
+114
@@ -0,0 +1,114 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { closeModal } from "/js/modals.js";
3
+
4
+const ASSET_BASE = "/plugins/_whats_new/webui/assets";
5
+
6
+const slides = [
7
+ {
8
+ id: "parallel-tools",
9
+ eyebrow: "Parallel execution",
10
+ title: "Parallel tool calls and subagents",
11
+ summary:
12
+ "Agent Zero can now split work across parallel tool and subagents calls and combine concurrent steps results.",
13
+ mediaType: "video",
14
+ media: `${ASSET_BASE}/parallel-subs.webm`,
15
+ mediaLabel:
16
+ "Four Agent Zero subagents working in parallel while the parent agent coordinates the result.",
17
+ bullets: [
18
+ "Launch coordinated subagents to explore separate paths at the same time.",
19
+ "Run mixed batches together: search queries, code execution, file reads, writes, and more.",
20
+ "Merge the results back into one answer without waiting through every call in sequence.",
21
+ ],
22
+ },
23
+ {
24
+ id: "mcp-configuration",
25
+ eyebrow: "MCP configuration",
26
+ title: "Redesigned MCP configuration UI",
27
+ summary:
28
+ "Global Settings and Projects now share a cleaner MCP setup flow for command and Remote URL transports.",
29
+ mediaType: "image",
30
+ media: `${ASSET_BASE}/mcp-servers.png`,
31
+ mediaLabel:
32
+ "The redesigned MCP servers screen showing server cards, transport controls, and raw JSON mode.",
33
+ bullets: [
34
+ "Configure npx, uvx, or custom command servers with clearer fields.",
35
+ "Connect Remote URL transports from the same accessible editor.",
36
+ "Switch to Raw JSON when you want to paste or move configurations between clients.",
37
+ ],
38
+ },
39
+ {
40
+ id: "skills-scanner",
41
+ eyebrow: "Agent security",
42
+ title: "Skills Scanner powered by Snyk Agent Scan",
43
+ summary:
44
+ "Scan your agent skills and MCP-connected surfaces for prompt injections and vulnerabilities.",
45
+ mediaType: "image",
46
+ media: `${ASSET_BASE}/skills-scanner.png`,
47
+ mediaLabel:
48
+ "The Skills Scanner screen showing Snyk Agent Scan controls and scan guidance.",
49
+ bullets: [
50
+ "Review skills with the same scanning flow you already use for plugins.",
51
+ "Find prompt-injection risks and vulnerable instructions before they reach runtime.",
52
+ "Catch risky skill instructions early, before they become part of an agent workflow.",
53
+ ],
54
+ },
55
+];
56
+
57
+export const store = createStore("whatsNew", {
58
+ slides,
59
+ currentIndex: 0,
60
+
61
+ onOpen() {
62
+ this.currentIndex = 0;
63
+ },
64
+
65
+ cleanup() {},
66
+
67
+ get currentSlide() {
68
+ return this.slides[this.currentIndex] || this.slides[0];
69
+ },
70
+
71
+ isFirst() {
72
+ return this.currentIndex <= 0;
73
+ },
74
+
75
+ isLast() {
76
+ return this.currentIndex >= this.slides.length - 1;
77
+ },
78
+
79
+ progressLabel() {
80
+ return `${this.currentIndex + 1} of ${this.slides.length}`;
81
+ },
82
+
83
+ dotLabel(index) {
84
+ const slide = this.slides[index];
85
+ return slide ? `Show ${slide.title}` : `Show item ${index + 1}`;
86
+ },
87
+
88
+ goTo(index) {
89
+ const nextIndex = Number(index);
90
+ if (!Number.isInteger(nextIndex)) return;
91
+ if (nextIndex < 0 || nextIndex >= this.slides.length) return;
92
+ this.currentIndex = nextIndex;
93
+ },
94
+
95
+ previous() {
96
+ if (!this.isFirst()) this.currentIndex -= 1;
97
+ },
98
+
99
+ next() {
100
+ if (this.isLast()) {
101
+ this.finish();
102
+ return;
103
+ }
104
+ this.currentIndex += 1;
105
+ },
106
+
107
+ finish() {
108
+ closeModal();
109
+ },
110
+
111
+ skip() {
112
+ closeModal();
113
+ },
114
+});
plugins/_whats_new/webui/whats-new.html
new
+259
@@ -0,0 +1,259 @@
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
+ <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>
258
+</body>
259
+</html>
tests/test_whats_new_static.py
new
+57
@@ -0,0 +1,57 @@
1
+from pathlib import Path
2
+
3
+
4
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
5
+
6
+
7
+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")
10
+
11
+ assert "What's New in Agent Zero" in html
12
+ assert "data-modal-footer" in html
13
+ assert "btn btn-ok" in html
14
+ assert "btn btn-field" in html
15
+ assert "/plugins/_whats_new/webui/whats-new-store.js" in html
16
+ assert "/plugins/_whats_new/webui/assets" in store
17
+ assert "Don't show this again" not in html + store
18
+
19
+ for asset in ["parallel-subs.webm", "mcp-servers.png", "skills-scanner.png"]:
20
+ assert asset in html + store
21
+ assert (PROJECT_ROOT / "plugins/_whats_new/webui/assets" / asset).exists()
22
+
23
+ assert "Parallel tool calls and subagents" in store
24
+ assert (
25
+ "Agent Zero can now split work across parallel tool and subagents calls and combine concurrent steps results."
26
+ in store
27
+ )
28
+ assert "Redesigned MCP configuration UI" in store
29
+ assert "Skills Scanner powered by Snyk Agent Scan" in store
30
+ assert "Remote URL transports" in store
31
+ assert "Raw JSON" in store
32
+ assert "prompt-injection risks" in store
33
+ assert "Catch risky skill instructions early" in store
34
+ assert "Include MCP servers in the same pass" not in store
35
+
36
+
37
+def test_whats_new_startup_trigger_is_version_gated():
38
+ content = (
39
+ PROJECT_ROOT / "plugins/_whats_new/extensions/webui/initFw_end/whats-new.js"
40
+ ).read_text(encoding="utf-8")
41
+
42
+ assert "globalThis.gitinfo?.version" in content
43
+ assert "a0_whats_new_seen_version" in content
44
+ assert "/plugins/_whats_new/webui/whats-new.html" in content
45
+ assert "compareVersions" in content
46
+ assert "shouldShowWhatsNew" in content
47
+ assert "modal-closed" in content
48
+ assert "markVersionSeen" in content
49
+ assert "Don't show this again" not in content
50
+
51
+
52
+def test_whats_new_plugin_manifest_is_always_enabled():
53
+ manifest = (PROJECT_ROOT / "plugins/_whats_new/plugin.yaml").read_text(encoding="utf-8")
54
+
55
+ assert "name: _whats_new" in manifest
56
+ assert "always_enabled: true" in manifest
57
+ assert "settings_sections: []" in manifest