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.
This commit is contained in:
Alessandro 2026-06-23 13:14:03 +02:00
parent 90abe05d2d
commit 82bb0b9292
6 changed files with 481 additions and 299 deletions

View file

@ -2,31 +2,37 @@
## Purpose
- Own the built-in version-gated "What's New" modal for showcasing new Agent Zero features after updates.
- Own the built-in version-gated "What's New" modal for showcasing 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.
- `webui/main.html` owns the canonical modal opened by startup and the Builtin Plugins `Open` button.
- `webui/whats-new.html` is a compatibility redirect to `webui/main.html`.
- `webui/` owns the Alpine store, copy, and showcase media assets.
- `extensions/webui/initFw_end/` owns the startup trigger that opens the modal once per newer installed version unless the user has permanently opted out.
## Local Contracts
- Do not add a "Don't show this again" control; dismissal records the current installed version as seen.
- Closing, Skip, or Done records only the current installed version as seen.
- Future updates should auto-open the modal again unless the user checks the modal's permanent opt-out checkbox.
- The permanent opt-out is stored in browser-local state under `a0_whats_new_never_show`.
- Honor the legacy `a0_whats_new_seen_version` browser-local marker as the last seen version.
- 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/`.
- Store seen-version and opt-out markers 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.
- Preserve `webui/main.html` so the plugin list exposes the standard `Open` action.
## 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.
- 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.
## Child DOX Index

View file

@ -1,19 +1,37 @@
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 MODAL_PATH = "/plugins/_whats_new/webui/main.html";
const LEGACY_MODAL_PATH = "/plugins/_whats_new/webui/whats-new.html";
const SEEN_VERSION_STORAGE_KEY = "a0_whats_new_seen_version";
const NEVER_SHOW_STORAGE_KEY = "a0_whats_new_never_show";
const INTERMEDIATE_ONCE_STORAGE_KEY = "a0_whats_new_seen_once";
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 storageValue(key) {
try {
return globalThis.localStorage?.getItem(key) || "";
} catch {
return "";
}
}
function storageSet(key, value) {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
// localStorage may be unavailable in private or locked-down browser modes.
}
}
function parseVersion(value) {
const raw = String(value || "").trim();
if (!raw || raw.toLowerCase() === "unknown") return null;
@ -31,6 +49,16 @@ function parseVersion(value) {
};
}
function parseStoredVersion(rawValue) {
if (!rawValue) return null;
try {
const parsed = JSON.parse(rawValue);
return parseVersion(parsed?.version || parsed?.raw || rawValue);
} catch {
return parseVersion(rawValue);
}
}
function compareVersions(left, right) {
if (!left || !right) return null;
for (let index = 0; index < left.parts.length; index += 1) {
@ -45,23 +73,43 @@ function currentVersion() {
return parseVersion(globalThis.gitinfo?.version || "");
}
function storedSeenVersion() {
try {
const rawValue = globalThis.localStorage?.getItem(STORAGE_KEY) || "";
if (!rawValue) return null;
function markVersionSeen(version = currentVersion()) {
if (!version) return;
storageSet(
SEEN_VERSION_STORAGE_KEY,
JSON.stringify({
version: version.raw,
seenAt: new Date().toISOString(),
}),
);
}
try {
const parsed = JSON.parse(rawValue);
return parseVersion(parsed?.version || parsed?.raw || rawValue);
} catch {
return parseVersion(rawValue);
}
function storedSeenVersion() {
const stored = parseStoredVersion(storageValue(SEEN_VERSION_STORAGE_KEY));
if (stored) return stored;
const intermediate = parseStoredVersion(storageValue(INTERMEDIATE_ONCE_STORAGE_KEY));
if (!intermediate) return null;
markVersionSeen(intermediate);
return intermediate;
}
function shouldNeverShow() {
const value = storageValue(NEVER_SHOW_STORAGE_KEY);
if (!value) return false;
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed.enabled !== false;
return Boolean(parsed);
} catch {
return null;
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
}
function shouldShowWhatsNew(version) {
function shouldShowWhatsNew(version = currentVersion()) {
if (shouldNeverShow()) return false;
if (!version) return false;
const seen = storedSeenVersion();
@ -71,25 +119,19 @@ function shouldShowWhatsNew(version) {
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 isWhatsNewPath(path = "") {
const cleaned = cleanPath(path);
return [MODAL_PATH, LEGACY_MODAL_PATH].some((candidate) => cleanPath(candidate) === cleaned);
}
function isWhatsNewOpen() {
return isModalOpen(MODAL_PATH) || isModalOpen(LEGACY_MODAL_PATH);
}
function scheduleRetry() {
if (busyRetries >= MAX_BUSY_RETRIES) return;
busyRetries += 1;
@ -102,21 +144,19 @@ function maybeOpenWhatsNew() {
const version = currentVersion();
if (!shouldShowWhatsNew(version)) return;
if (isModalOpen(MODAL_PATH)) return;
if (isWhatsNewOpen()) 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;
if (isWhatsNewPath(closedPath)) {
markVersionSeen();
return;
}

View file

@ -0,0 +1,308 @@
<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">
<div class="whats-new-footer-progress">
<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>
<label class="whats-new-never-show">
<input
type="checkbox"
:checked="$store.whatsNew.neverShowAgain"
@change="$store.whatsNew.setNeverShowAgain($event.target.checked)"
/>
<span>Don't show automatically again</span>
</label>
</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-footer-left {
flex-wrap: wrap;
}
.whats-new-footer-progress {
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));
}
.whats-new-never-show {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
color: var(--color-text-muted);
font-size: 0.78rem;
cursor: pointer;
user-select: none;
}
.whats-new-never-show input {
width: 0.95rem;
height: 0.95rem;
margin: 0;
flex: 0 0 auto;
accent-color: #4248f1;
}
.whats-new-never-show span {
min-width: 0;
white-space: normal;
}
@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-progress {
justify-content: space-between;
}
.whats-new-footer-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
</style>
</body>
</html>

View file

@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
import { closeModal } from "/js/modals.js";
const ASSET_BASE = "/plugins/_whats_new/webui/assets";
const NEVER_SHOW_STORAGE_KEY = "a0_whats_new_never_show";
const slides = [
{
@ -54,15 +55,58 @@ const slides = [
},
];
function storageValue(key) {
try {
return globalThis.localStorage?.getItem(key) || "";
} catch {
return "";
}
}
function isNeverShowEnabled() {
const value = storageValue(NEVER_SHOW_STORAGE_KEY);
if (!value) return false;
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed.enabled !== false;
return Boolean(parsed);
} catch {
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
}
function persistNeverShowPreference(enabled) {
try {
if (enabled) {
globalThis.localStorage?.setItem(
NEVER_SHOW_STORAGE_KEY,
JSON.stringify({
enabled: true,
updatedAt: new Date().toISOString(),
}),
);
} else {
globalThis.localStorage?.removeItem(NEVER_SHOW_STORAGE_KEY);
}
} catch {
// localStorage may be unavailable in private or locked-down browser modes.
}
}
export const store = createStore("whatsNew", {
slides,
currentIndex: 0,
neverShowAgain: false,
onOpen() {
this.currentIndex = 0;
this.neverShowAgain = isNeverShowEnabled();
},
cleanup() {},
cleanup() {
persistNeverShowPreference(this.neverShowAgain);
},
get currentSlide() {
return this.slides[this.currentIndex] || this.slides[0];
@ -96,6 +140,11 @@ export const store = createStore("whatsNew", {
if (!this.isFirst()) this.currentIndex -= 1;
},
setNeverShowAgain(value) {
this.neverShowAgain = Boolean(value);
persistNeverShowPreference(this.neverShowAgain);
},
next() {
if (this.isLast()) {
this.finish();

View file

@ -1,259 +1,20 @@
<html class="whats-new-modal">
<html>
<head>
<title>What's New in Agent Zero</title>
<script type="module">
import { store } from "/plugins/_whats_new/webui/whats-new-store.js";
import { closeModal, openModal } from "/js/modals.js";
const legacyPath = "/plugins/_whats_new/webui/whats-new.html";
const mainPath = "/plugins/_whats_new/webui/main.html";
queueMicrotask(async () => {
await closeModal(legacyPath);
await openModal(mainPath);
});
</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>
<div class="loading">Opening What's New...</div>
</body>
</html>