diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 92522fa0b..4a3ca693b 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -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. | diff --git a/plugins/_whats_new/AGENTS.md b/plugins/_whats_new/AGENTS.md new file mode 100644 index 000000000..78dbf755d --- /dev/null +++ b/plugins/_whats_new/AGENTS.md @@ -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. diff --git a/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js b/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js new file mode 100644 index 000000000..9e870c4d9 --- /dev/null +++ b/plugins/_whats_new/extensions/webui/initFw_end/whats-new.js @@ -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); +} diff --git a/plugins/_whats_new/plugin.yaml b/plugins/_whats_new/plugin.yaml new file mode 100644 index 000000000..b60286bd8 --- /dev/null +++ b/plugins/_whats_new/plugin.yaml @@ -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 diff --git a/plugins/_whats_new/webui/assets/mcp-servers.png b/plugins/_whats_new/webui/assets/mcp-servers.png new file mode 100644 index 000000000..9d651ad92 Binary files /dev/null and b/plugins/_whats_new/webui/assets/mcp-servers.png differ diff --git a/plugins/_whats_new/webui/assets/parallel-subs.webm b/plugins/_whats_new/webui/assets/parallel-subs.webm new file mode 100644 index 000000000..2c418c1c1 Binary files /dev/null and b/plugins/_whats_new/webui/assets/parallel-subs.webm differ diff --git a/plugins/_whats_new/webui/assets/skills-scanner.png b/plugins/_whats_new/webui/assets/skills-scanner.png new file mode 100644 index 000000000..f2a8ba2cc Binary files /dev/null and b/plugins/_whats_new/webui/assets/skills-scanner.png differ diff --git a/plugins/_whats_new/webui/whats-new-store.js b/plugins/_whats_new/webui/whats-new-store.js new file mode 100644 index 000000000..6de8bb40d --- /dev/null +++ b/plugins/_whats_new/webui/whats-new-store.js @@ -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(); + }, +}); diff --git a/plugins/_whats_new/webui/whats-new.html b/plugins/_whats_new/webui/whats-new.html new file mode 100644 index 000000000..1195f626b --- /dev/null +++ b/plugins/_whats_new/webui/whats-new.html @@ -0,0 +1,259 @@ + + + What's New in Agent Zero + + + + +
+ +
+ + + + diff --git a/tests/test_whats_new_static.py b/tests/test_whats_new_static.py new file mode 100644 index 000000000..9ab73e469 --- /dev/null +++ b/tests/test_whats_new_static.py @@ -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