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.
This commit is contained in:
Alessandro 2026-06-19 17:26:29 +02:00
parent 98e51fbb2a
commit 2a81e37499
10 changed files with 609 additions and 0 deletions

View file

@ -93,4 +93,5 @@ Direct child DOX files:
| [_text_editor/AGENTS.md](_text_editor/AGENTS.md) | Native text read, write, and patch tool. |
| [_time_travel/AGENTS.md](_time_travel/AGENTS.md) | Workspace history, diff, travel, snapshot, and revert flows. |
| [_whatsapp_integration/AGENTS.md](_whatsapp_integration/AGENTS.md) | WhatsApp Baileys bridge integration. |
| [_whats_new/AGENTS.md](_whats_new/AGENTS.md) | Version-gated What's New showcase modal and startup trigger. |
| [_whisper_stt/AGENTS.md](_whisper_stt/AGENTS.md) | Whisper speech-to-text integration. |

View file

@ -0,0 +1,33 @@
# What's New Plugin DOX
## Purpose
- Own the built-in version-gated "What's New" modal for showcasing new Agent Zero features after updates.
## Ownership
- `plugin.yaml` owns metadata and always-enabled status.
- `webui/` owns the modal markup, Alpine store, copy, and showcase media assets.
- `extensions/webui/initFw_end/` owns the startup trigger that opens the modal when the installed version is newer than the locally seen version.
## Local Contracts
- Do not add a "Don't show this again" control; dismissal records the current installed version as seen.
- Keep the modal copy concise, left-aligned, and paired with feature media.
- Keep modal actions in the pinned footer using the shared Agent Zero button classes.
- Store the seen-version marker in browser-local state only; do not persist this under `usr/`.
## Work Guidance
- Add showcase assets under `webui/assets/` and reference them through `/plugins/_whats_new/webui/assets/...`.
- Keep the startup extension idempotent and tolerant of missing or non-comparable version labels.
- Prefer release-line comparisons over development commit-distance comparisons so local development builds do not reopen the modal on every commit.
## Verification
- Run `pytest tests/test_whats_new_static.py` after changing the modal, trigger, or assets.
- Smoke-test startup display, slide navigation, dismissal, and same-version reload behavior in the WebUI.
## Child DOX Index
No child DOX files.

View file

@ -0,0 +1,137 @@
import { getModalStack, isModalOpen, openModal } from "/js/modals.js";
const MODAL_PATH = "/plugins/_whats_new/webui/whats-new.html";
const STORAGE_KEY = "a0_whats_new_seen_version";
const STARTUP_DELAY_MS = 1200;
const RETRY_DELAY_MS = 900;
const MAX_BUSY_RETRIES = 20;
let initialized = false;
let busyRetries = 0;
let openedForVersion = null;
function cleanPath(path = "") {
return String(path || "").replace(/^\/+/, "");
}
function parseVersion(value) {
const raw = String(value || "").trim();
if (!raw || raw.toLowerCase() === "unknown") return null;
const match = /(?:^|[\s(])v?(\d+)\.(\d+)(?:\.(\d+))?(?:\+(\d+))?/.exec(raw);
if (!match) return null;
return {
raw,
parts: [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3] || "0", 10),
],
};
}
function compareVersions(left, right) {
if (!left || !right) return null;
for (let index = 0; index < left.parts.length; index += 1) {
if (left.parts[index] !== right.parts[index]) {
return left.parts[index] - right.parts[index];
}
}
return 0;
}
function currentVersion() {
return parseVersion(globalThis.gitinfo?.version || "");
}
function storedSeenVersion() {
try {
const rawValue = globalThis.localStorage?.getItem(STORAGE_KEY) || "";
if (!rawValue) return null;
try {
const parsed = JSON.parse(rawValue);
return parseVersion(parsed?.version || parsed?.raw || rawValue);
} catch {
return parseVersion(rawValue);
}
} catch {
return null;
}
}
function shouldShowWhatsNew(version) {
if (!version) return false;
const seen = storedSeenVersion();
if (!seen) return true;
const comparison = compareVersions(version, seen);
return comparison !== null && comparison > 0;
}
function markVersionSeen(version = currentVersion()) {
if (!version) return;
try {
globalThis.localStorage?.setItem(
STORAGE_KEY,
JSON.stringify({
version: version.raw,
seenAt: new Date().toISOString(),
}),
);
} catch {
// localStorage may be unavailable in private or locked-down browser modes.
}
}
function anotherModalIsOpen() {
return getModalStack().length > 0 || Boolean(document.querySelector(".modal.show"));
}
function scheduleRetry() {
if (busyRetries >= MAX_BUSY_RETRIES) return;
busyRetries += 1;
window.setTimeout(() => {
maybeOpenWhatsNew();
}, RETRY_DELAY_MS);
}
function maybeOpenWhatsNew() {
const version = currentVersion();
if (!shouldShowWhatsNew(version)) return;
if (isModalOpen(MODAL_PATH)) return;
if (anotherModalIsOpen()) {
scheduleRetry();
return;
}
openedForVersion = version;
void openModal(MODAL_PATH);
}
function handleModalClosed(event) {
const closedPath = event?.detail?.modalPath || "";
if (cleanPath(closedPath) === cleanPath(MODAL_PATH)) {
markVersionSeen(openedForVersion || currentVersion());
openedForVersion = null;
return;
}
window.setTimeout(() => {
maybeOpenWhatsNew();
}, RETRY_DELAY_MS);
}
export default function initWhatsNew() {
if (initialized) return;
initialized = true;
document.addEventListener("modal-closed", handleModalClosed);
window.setTimeout(() => {
maybeOpenWhatsNew();
}, STARTUP_DELAY_MS);
}

View file

@ -0,0 +1,8 @@
name: _whats_new
title: What's New
description: Built-in version-gated showcase modal for new Agent Zero features.
version: 1.0.0
settings_sections: []
always_enabled: true
per_project_config: false
per_agent_config: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View file

@ -0,0 +1,114 @@
import { createStore } from "/js/AlpineStore.js";
import { closeModal } from "/js/modals.js";
const ASSET_BASE = "/plugins/_whats_new/webui/assets";
const slides = [
{
id: "parallel-tools",
eyebrow: "Parallel execution",
title: "Parallel tool calls and subagents",
summary:
"Agent Zero can now split work across parallel tool and subagents calls and combine concurrent steps results.",
mediaType: "video",
media: `${ASSET_BASE}/parallel-subs.webm`,
mediaLabel:
"Four Agent Zero subagents working in parallel while the parent agent coordinates the result.",
bullets: [
"Launch coordinated subagents to explore separate paths at the same time.",
"Run mixed batches together: search queries, code execution, file reads, writes, and more.",
"Merge the results back into one answer without waiting through every call in sequence.",
],
},
{
id: "mcp-configuration",
eyebrow: "MCP configuration",
title: "Redesigned MCP configuration UI",
summary:
"Global Settings and Projects now share a cleaner MCP setup flow for command and Remote URL transports.",
mediaType: "image",
media: `${ASSET_BASE}/mcp-servers.png`,
mediaLabel:
"The redesigned MCP servers screen showing server cards, transport controls, and raw JSON mode.",
bullets: [
"Configure npx, uvx, or custom command servers with clearer fields.",
"Connect Remote URL transports from the same accessible editor.",
"Switch to Raw JSON when you want to paste or move configurations between clients.",
],
},
{
id: "skills-scanner",
eyebrow: "Agent security",
title: "Skills Scanner powered by Snyk Agent Scan",
summary:
"Scan your agent skills and MCP-connected surfaces for prompt injections and vulnerabilities.",
mediaType: "image",
media: `${ASSET_BASE}/skills-scanner.png`,
mediaLabel:
"The Skills Scanner screen showing Snyk Agent Scan controls and scan guidance.",
bullets: [
"Review skills with the same scanning flow you already use for plugins.",
"Find prompt-injection risks and vulnerable instructions before they reach runtime.",
"Catch risky skill instructions early, before they become part of an agent workflow.",
],
},
];
export const store = createStore("whatsNew", {
slides,
currentIndex: 0,
onOpen() {
this.currentIndex = 0;
},
cleanup() {},
get currentSlide() {
return this.slides[this.currentIndex] || this.slides[0];
},
isFirst() {
return this.currentIndex <= 0;
},
isLast() {
return this.currentIndex >= this.slides.length - 1;
},
progressLabel() {
return `${this.currentIndex + 1} of ${this.slides.length}`;
},
dotLabel(index) {
const slide = this.slides[index];
return slide ? `Show ${slide.title}` : `Show item ${index + 1}`;
},
goTo(index) {
const nextIndex = Number(index);
if (!Number.isInteger(nextIndex)) return;
if (nextIndex < 0 || nextIndex >= this.slides.length) return;
this.currentIndex = nextIndex;
},
previous() {
if (!this.isFirst()) this.currentIndex -= 1;
},
next() {
if (this.isLast()) {
this.finish();
return;
}
this.currentIndex += 1;
},
finish() {
closeModal();
},
skip() {
closeModal();
},
});

View file

@ -0,0 +1,259 @@
<html class="whats-new-modal">
<head>
<title>What's New in Agent Zero</title>
<script type="module">
import { store } from "/plugins/_whats_new/webui/whats-new-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.whatsNew">
<div class="whats-new-shell" x-init="$store.whatsNew.onOpen()" x-destroy="$store.whatsNew.cleanup()">
<section class="whats-new-media-panel" :aria-label="$store.whatsNew.currentSlide.mediaLabel">
<template x-if="$store.whatsNew.currentSlide.mediaType === 'video'">
<video
class="whats-new-media"
:src="$store.whatsNew.currentSlide.media"
:aria-label="$store.whatsNew.currentSlide.mediaLabel"
autoplay
loop
muted
playsinline
></video>
</template>
<template x-if="$store.whatsNew.currentSlide.mediaType === 'image'">
<img
class="whats-new-media"
:src="$store.whatsNew.currentSlide.media"
:alt="$store.whatsNew.currentSlide.mediaLabel"
/>
</template>
</section>
<section class="whats-new-copy">
<div class="whats-new-eyebrow" x-text="$store.whatsNew.currentSlide.eyebrow"></div>
<h3 x-text="$store.whatsNew.currentSlide.title"></h3>
<p class="whats-new-summary" x-text="$store.whatsNew.currentSlide.summary"></p>
<ul class="whats-new-bullets">
<template x-for="item in $store.whatsNew.currentSlide.bullets" :key="item">
<li x-text="item"></li>
</template>
</ul>
</section>
<div class="modal-footer whats-new-footer" data-modal-footer>
<div class="whats-new-footer-left">
<span class="whats-new-progress-label" x-text="$store.whatsNew.progressLabel()"></span>
<div class="whats-new-progress-dots" role="tablist" aria-label="What's New slides">
<template x-for="(slide, index) in $store.whatsNew.slides" :key="slide.id">
<button
type="button"
class="whats-new-dot"
role="tab"
:class="{ 'active': index === $store.whatsNew.currentIndex }"
:aria-label="$store.whatsNew.dotLabel(index)"
:aria-selected="index === $store.whatsNew.currentIndex ? 'true' : 'false'"
@click="$store.whatsNew.goTo(index)"
></button>
</template>
</div>
</div>
<div class="whats-new-footer-actions">
<button type="button" class="btn btn-cancel" @click="$store.whatsNew.skip()">Skip</button>
<button
type="button"
class="btn btn-field"
x-show="!$store.whatsNew.isFirst()"
@click="$store.whatsNew.previous()"
>
Back
</button>
<button type="button" class="btn btn-ok" @click="$store.whatsNew.next()">
<span x-text="$store.whatsNew.isLast() ? 'Done' : 'Next'"></span>
</button>
</div>
</div>
</div>
</template>
</div>
<style>
.modal-inner.whats-new-modal {
width: min(92vw, 760px);
max-height: min(88vh, 820px);
}
.whats-new-shell {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.whats-new-media-panel {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1476 / 842;
min-height: 180px;
max-height: min(44vh, 360px);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
border-radius: 7px;
background:
radial-gradient(circle at 18% 12%, color-mix(in srgb, #4248f1 18%, transparent), transparent 34%),
color-mix(in srgb, var(--color-panel) 78%, #000 22%);
}
.whats-new-media {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: color-mix(in srgb, var(--color-panel) 82%, #000 18%);
}
.whats-new-copy {
display: flex;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
color: var(--color-text);
text-align: left;
}
.whats-new-eyebrow {
color: color-mix(in srgb, #2196f3 86%, var(--color-text));
font-size: 0.74rem;
font-weight: 760;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.whats-new-copy h3 {
margin: 0;
color: var(--color-text);
font-size: clamp(1.35rem, 2.5vw, 1.8rem);
font-weight: 760;
line-height: 1.12;
letter-spacing: 0;
}
.whats-new-summary {
max-width: 62ch;
margin: 0;
color: var(--color-text-muted);
font-size: 0.98rem;
line-height: 1.45;
}
.whats-new-bullets {
display: grid;
gap: 0.45rem;
margin: 0.25rem 0 0;
padding: 0;
list-style: none;
}
.whats-new-bullets li {
position: relative;
min-width: 0;
padding-left: 1.1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.42;
}
.whats-new-bullets li::before {
content: "";
position: absolute;
left: 0;
top: 0.58em;
width: 0.42rem;
height: 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, #4248f1 72%, #2196f3 28%);
box-shadow: 0 0 0 3px color-mix(in srgb, #4248f1 18%, transparent);
}
.whats-new-footer {
justify-content: space-between;
gap: 1rem;
}
.whats-new-footer-left,
.whats-new-footer-actions {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.whats-new-progress-label {
color: var(--color-text-muted);
font-size: 0.78rem;
white-space: nowrap;
}
.whats-new-progress-dots {
display: flex;
align-items: center;
gap: 0.35rem;
}
.whats-new-dot {
width: 0.48rem;
height: 0.48rem;
padding: 0;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 45%, transparent);
cursor: pointer;
transition: transform 0.16s ease, background-color 0.16s ease, width 0.16s ease;
}
.whats-new-dot.active {
width: 1.2rem;
background: color-mix(in srgb, #4248f1 74%, #2196f3 26%);
}
.whats-new-dot:hover {
transform: translateY(-1px);
background: color-mix(in srgb, #2196f3 72%, var(--color-text));
}
@media (max-width: 640px) {
.modal-inner.whats-new-modal {
width: min(94vw, 760px);
}
.whats-new-media-panel {
min-height: 150px;
max-height: 32vh;
}
.whats-new-footer,
.whats-new-footer-left,
.whats-new-footer-actions {
align-items: stretch;
}
.whats-new-footer {
flex-direction: column;
}
.whats-new-footer-left {
justify-content: space-between;
}
.whats-new-footer-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,57 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_whats_new_modal_uses_showcase_assets_and_branded_footer():
html = (PROJECT_ROOT / "plugins/_whats_new/webui/whats-new.html").read_text(encoding="utf-8")
store = (PROJECT_ROOT / "plugins/_whats_new/webui/whats-new-store.js").read_text(encoding="utf-8")
assert "What's New in Agent Zero" in html
assert "data-modal-footer" in html
assert "btn btn-ok" in html
assert "btn btn-field" in html
assert "/plugins/_whats_new/webui/whats-new-store.js" in html
assert "/plugins/_whats_new/webui/assets" in store
assert "Don't show this again" not in html + store
for asset in ["parallel-subs.webm", "mcp-servers.png", "skills-scanner.png"]:
assert asset in html + store
assert (PROJECT_ROOT / "plugins/_whats_new/webui/assets" / asset).exists()
assert "Parallel tool calls and subagents" in store
assert (
"Agent Zero can now split work across parallel tool and subagents calls and combine concurrent steps results."
in store
)
assert "Redesigned MCP configuration UI" in store
assert "Skills Scanner powered by Snyk Agent Scan" in store
assert "Remote URL transports" in store
assert "Raw JSON" in store
assert "prompt-injection risks" in store
assert "Catch risky skill instructions early" in store
assert "Include MCP servers in the same pass" not in store
def test_whats_new_startup_trigger_is_version_gated():
content = (
PROJECT_ROOT / "plugins/_whats_new/extensions/webui/initFw_end/whats-new.js"
).read_text(encoding="utf-8")
assert "globalThis.gitinfo?.version" in content
assert "a0_whats_new_seen_version" in content
assert "/plugins/_whats_new/webui/whats-new.html" in content
assert "compareVersions" in content
assert "shouldShowWhatsNew" in content
assert "modal-closed" in content
assert "markVersionSeen" in content
assert "Don't show this again" not in content
def test_whats_new_plugin_manifest_is_always_enabled():
manifest = (PROJECT_ROOT / "plugins/_whats_new/plugin.yaml").read_text(encoding="utf-8")
assert "name: _whats_new" in manifest
assert "always_enabled: true" in manifest
assert "settings_sections: []" in manifest