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 @@ + +
+