diff --git a/plugins/_whats_new/AGENTS.md b/plugins/_whats_new/AGENTS.md index 78dbf755d..3c4f6b2b4 100644 --- a/plugins/_whats_new/AGENTS.md +++ b/plugins/_whats_new/AGENTS.md @@ -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 diff --git a/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js b/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js index 9e870c4d9..9d24c34b6 100644 --- a/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js +++ b/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js @@ -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; } diff --git a/plugins/_whats_new/webui/main.html b/plugins/_whats_new/webui/main.html new file mode 100644 index 000000000..66be15082 --- /dev/null +++ b/plugins/_whats_new/webui/main.html @@ -0,0 +1,308 @@ + + + What's New in Agent Zero + + + + +
+ +
+ + + + diff --git a/plugins/_whats_new/webui/whats-new-store.js b/plugins/_whats_new/webui/whats-new-store.js index 6de8bb40d..37fa51445 100644 --- a/plugins/_whats_new/webui/whats-new-store.js +++ b/plugins/_whats_new/webui/whats-new-store.js @@ -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(); diff --git a/plugins/_whats_new/webui/whats-new.html b/plugins/_whats_new/webui/whats-new.html index 1195f626b..4911ca679 100644 --- a/plugins/_whats_new/webui/whats-new.html +++ b/plugins/_whats_new/webui/whats-new.html @@ -1,259 +1,20 @@ - + What's New in Agent Zero -
- -
- - +
Opening What's New...
diff --git a/tests/test_whats_new_static.py b/tests/test_whats_new_static.py index 9ab73e469..7eb782441 100644 --- a/tests/test_whats_new_static.py +++ b/tests/test_whats_new_static.py @@ -2,19 +2,22 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] +WHATS_NEW_PLUGIN = PROJECT_ROOT / "plugins/_whats_new" 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") + html = (WHATS_NEW_PLUGIN / "webui/main.html").read_text(encoding="utf-8") + store = (WHATS_NEW_PLUGIN / "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 "type=\"checkbox\"" in html + assert "Don't show automatically again" 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 + assert "a0_whats_new_never_show" in store for asset in ["parallel-subs.webm", "mcp-servers.png", "skills-scanner.png"]: assert asset in html + store @@ -34,23 +37,38 @@ def test_whats_new_modal_uses_showcase_assets_and_branded_footer(): assert "Include MCP servers in the same pass" not in store -def test_whats_new_startup_trigger_is_version_gated(): +def test_whats_new_legacy_modal_path_redirects_to_main_screen(): + html = (WHATS_NEW_PLUGIN / "webui/whats-new.html").read_text(encoding="utf-8") + + assert "/plugins/_whats_new/webui/main.html" in html + assert "openModal(mainPath)" in html + assert "What's New in Agent Zero" in html + + +def test_whats_new_startup_trigger_is_version_gated_with_opt_out(): content = ( - PROJECT_ROOT / "plugins/_whats_new/extensions/webui/initFw_end/whats-new.js" + WHATS_NEW_PLUGIN / "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 "a0_whats_new_never_show" in content + assert "/plugins/_whats_new/webui/main.html" in content assert "/plugins/_whats_new/webui/whats-new.html" in content assert "compareVersions" in content + assert "storedSeenVersion" in content assert "shouldShowWhatsNew" in content + assert "shouldNeverShow" in content assert "modal-closed" in content assert "markVersionSeen" in content - assert "Don't show this again" not in content + + +def test_whats_new_exposes_builtin_plugin_open_screen(): + assert (WHATS_NEW_PLUGIN / "webui/main.html").exists() def test_whats_new_plugin_manifest_is_always_enabled(): - manifest = (PROJECT_ROOT / "plugins/_whats_new/plugin.yaml").read_text(encoding="utf-8") + manifest = (WHATS_NEW_PLUGIN / "plugin.yaml").read_text(encoding="utf-8") assert "name: _whats_new" in manifest assert "always_enabled: true" in manifest