diff --git a/plugins/example-dashboard/dashboard/dist/index.js b/plugins/example-dashboard/dashboard/dist/index.js index a54916be41..04092348ff 100644 --- a/plugins/example-dashboard/dashboard/dist/index.js +++ b/plugins/example-dashboard/dashboard/dist/index.js @@ -91,4 +91,29 @@ // Register this plugin — the dashboard picks it up automatically. window.__HERMES_PLUGINS__.register("example", ExamplePage); + + // ───────────────────────────────────────────────────────────────────── + // Page-scoped slot demo: inject a small banner at the top of /sessions. + // + // Built-in pages expose named slots (:top, :bottom) that + // plugins can populate without overriding the whole route. The + // manifest lists the slots we use in its `slots` array so the shell + // knows to render there. + // ───────────────────────────────────────────────────────────────────── + function SessionsTopBanner() { + return React.createElement(Card, { + className: "border-dashed", + }, + React.createElement(CardContent, { className: "flex items-center gap-3 py-2" }, + React.createElement(Badge, { variant: "outline" }, "Example"), + React.createElement("span", { + className: "text-xs text-muted-foreground", + }, "This banner was injected into the Sessions page by the example plugin via the ", + React.createElement("code", { className: "font-courier" }, "sessions:top"), + " slot."), + ), + ); + } + + window.__HERMES_PLUGINS__.registerSlot("example", "sessions:top", SessionsTopBanner); })(); diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json index 2111bff5e7..95fce2f100 100644 --- a/plugins/example-dashboard/dashboard/manifest.json +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -8,6 +8,7 @@ "path": "/example", "position": "after:skills" }, + "slots": ["sessions:top"], "entry": "dist/index.js", "api": "plugin_api.py" } diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e83f5bdeb3..e7b3b03305 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1678,6 +1678,45 @@ class TestDashboardPluginManifestExtensions: entry = next(p for p in plugins if p["name"] == "mixed-slots") assert entry["slots"] == ["sidebar", "header-right"] + def test_page_scoped_slots_preserved(self, tmp_path, monkeypatch): + """Page-scoped slot names (e.g. ``sessions:top``) round-trip through + the manifest loader untouched. The backend has no allowlist — the + frontend ```` placements decide what actually + renders — but the loader must not mangle colons in slot names.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + self._write_plugin(tmp_path, "page-slots", { + "name": "page-slots", + "label": "Page Slots", + "tab": {"path": "/page-slots", "hidden": True}, + "slots": [ + "sessions:top", + "analytics:bottom", + "logs:top", + "skills:bottom", + "config:top", + "env:bottom", + "docs:top", + "cron:bottom", + "chat:top", + ], + "entry": "dist/index.js", + }) + from hermes_cli import web_server + web_server._dashboard_plugins_cache = None + plugins = web_server._get_dashboard_plugins(force_rescan=True) + entry = next(p for p in plugins if p["name"] == "page-slots") + assert entry["slots"] == [ + "sessions:top", + "analytics:bottom", + "logs:top", + "skills:bottom", + "config:top", + "env:bottom", + "docs:top", + "cron:bottom", + "chat:top", + ] + # --------------------------------------------------------------------------- # /api/pty WebSocket — terminal bridge for the dashboard "Chat" tab. diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index ba30612170..63dd15e4a3 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; const PERIODS = [ { label: "7d", days: 7 }, @@ -350,6 +351,7 @@ export default function AnalyticsPage() { return (
+ {loading && !data && (
@@ -409,6 +411,7 @@ export default function AnalyticsPage() { )} +
); } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 639c6324fa..80398104a1 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -32,6 +32,7 @@ import { useSearchParams } from "react-router-dom"; import { ChatSidebar } from "@/components/ChatSidebar"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; function buildWsUrl( token: string, @@ -670,6 +671,7 @@ export default function ChatPage() { return (
+ {mobileModelToolsPortal} {banner && ( @@ -732,6 +734,7 @@ export default function ChatPage() {
)}
+
); } diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 80cef29e4c..dcd387a922 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -39,6 +39,7 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; +import { PluginSlot } from "@/plugins"; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -313,6 +314,7 @@ export default function ConfigPage() { return (
+ {/* ═══════════════ Header Bar ═══════════════ */} @@ -505,6 +507,7 @@ export default function ConfigPage() {
)} + ); } diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 10fba6913e..63478fa74d 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectOption } from "@/components/ui/select"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; function formatTime(iso?: string | null): string { if (!iso) return "—"; @@ -149,6 +150,7 @@ export default function CronPage() { return (
+ ))}
+ ); } diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx index 5861aeccc1..2e1a6491fa 100644 --- a/web/src/pages/DocsPage.tsx +++ b/web/src/pages/DocsPage.tsx @@ -4,6 +4,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { PluginSlot } from "@/plugins"; export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/"; @@ -38,6 +39,7 @@ export default function DocsPage() { "pt-1 sm:pt-2", )} > +