mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(dashboard): page-scoped plugin slots for built-in pages (#15658)
* fix(terminal): three-layer defense against watch_patterns notification spam Background processes that stack notify_on_complete=True with watch_patterns can flood the user with duplicate, delayed notifications — matches deliver asynchronously via the completion queue and continue arriving minutes after the process has exited. The docstring warning against this (PR #12113) has proven insufficient; agents still misuse the combination. Three layered defenses, each sufficient on its own: 1. Mutual exclusion (terminal_tool.py): When both flags are set on a background process, drop watch_patterns with a warning. notify_on_complete wins because 'let me know when it's done' is the more useful signal and fires exactly once. Extracted as _resolve_notification_flag_conflict() so the rule is testable in isolation. 2. Suppress-after-exit (process_registry.py): _check_watch_patterns() now bails the moment session.exited is True. Post-exit chunks (buffered reads draining after the process is gone) no longer produce notifications. This is the fix flagged as future work in session 20260418_020302_79881c. 3. Global circuit breaker (process_registry.py): Per-session rate limits don't catch the sibling-flood case — N concurrent processes can each stay under 8/10s and still collectively spam. New WATCH_GLOBAL_MAX_PER_WINDOW=15 cap trips a 30-second cooldown across ALL sessions, emits a single watch_overflow_tripped event, silently counts dropped events, and emits a watch_overflow_released summary when the cooldown ends. Also updates the tool schema + docstring to document the new behavior. Tests: 8 new tests covering all three fixes (suppress-after-exit x2, mutual-exclusion resolver x4, global breaker trip/cooldown/release x2). All 60 tests across test_watch_patterns.py, test_notify_on_complete.py, test_terminal_tool.py pass. Real-world trigger: self-inflicted in session 20260425_051924 — three concurrent hermes-sweeper review subprocesses each set watch_patterns= ['failed validation', 'errored'] AND notify_on_complete=True, then iterated over multiple items, producing enough matches per process to defeat the per-session cap while staying under the global cap that didn't yet exist. * fix(terminal): aggressive 1-per-15s watch_patterns rate limit + strike-3 promotion Per Teknium's direction, the watch_patterns rate limit is now much more aggressive and self-healing. ## New rule — per session - HARD cap: 1 watch-match notification per 15 seconds per process. - Any match arriving inside the cooldown window is dropped and counts as ONE strike for that window (many drops in the same window still = 1 strike). - After 3 consecutive strike windows, watch_patterns is permanently disabled for the session and the session is auto-promoted to notify_on_complete semantics — exactly one notification when the process actually exits. - A cooldown window that expires with zero drops resets the consecutive strike counter — healthy cadence is forgiven. ## Schema + docstring rewritten The tool schema description now gives the model explicit guidance: - notify_on_complete is 'the right choice for almost every long-running task' - watch_patterns is for RARE one-shot signals on LONG-LIVED processes - Do NOT use watch_patterns with loops/batch jobs — error patterns fire every iteration and will hit the strike limit fast - Mutual exclusion is stated on both parameter descriptions - 1/15s cooldown and 3-strike promotion are stated in the watch_patterns description so the model sees the contract every turn ## Removed - WATCH_MAX_PER_WINDOW (8/10s) and WATCH_OVERLOAD_KILL_SECONDS (45) — the new 1/15s limit subsumes both; keeping them would double-count. - _watch_window_hits / _watch_window_start / _watch_overload_since fields on ProcessSession. Replaced by _watch_last_emit_at / _watch_cooldown_until / _watch_strike_candidate / _watch_consecutive_strikes. ## Kept - Global circuit breaker across all sessions (15/10s → 30s cooldown) as a secondary safety net for concurrent siblings. Still valuable when 20 short-lived processes each fire once — none individually violates the per-session limit. - Suppress-after-exit guard. - Mutual exclusion resolver at the tool entry point. ## Tests - 6 new tests in TestPerSessionRateLimit covering: first match delivers, second in cooldown suppressed, multi-drop = single strike, 3 strikes disables + promotes, clean window resets counter, suppressed count carried to next emit. - Global circuit breaker tests rewritten to use fresh sessions instead of hacking removed per-window fields. - 50/50 watch_patterns + notify_on_complete tests pass. - 60/60 including test_terminal_tool.py pass. * feat(dashboard): page-scoped plugin slots for built-in pages Dashboard plugins can now inject components into specific built-in pages (Sessions, Analytics, Logs, Cron, Skills, Config, Env, Docs, Chat) without overriding the whole route. Previously, plugins could only: 1. Add new tabs (tab.path) 2. Replace whole built-in pages (tab.override) 3. Inject into global shell slots (header-*, footer-*, pre-main, ...) None of those let a plugin add a banner, card, or widget to an existing page. The new <page>:top / <page>:bottom slots close that gap, reusing the existing registerSlot() API. Changes - web/src/plugins/slots.ts: 18 new KNOWN_SLOT_NAMES entries (sessions:top, sessions:bottom, analytics:top, ..., chat:bottom), grouped under "Shell-wide" vs "Page-scoped" in the docblock - web/src/pages/*: each built-in page now renders <PluginSlot name="<page>:top" /> as the first child of its outer wrapper and <PluginSlot name="<page>:bottom" /> as the last child -- zero visual cost when no plugin registers - plugins/example-dashboard: registers a demo banner into sessions:top via registerSlot(), with matching slots entry in the manifest -- so freshly-setup users can see what page-scoped slots look like without writing any plugin code - website/docs: new "Page-scoped slots" table in the plugin authoring guide, with a worked example - tests/hermes_cli/test_web_server.py: round-trip test for colon-bearing slot names (sessions:top, analytics:bottom, ...) Validation - npm run build: clean (tsc -b + vite build, 2761 modules) - scripts/run_tests.sh tests/hermes_cli/test_web_server.py::TestDashboardPluginManifestExtensions: 5/5 pass
This commit is contained in:
@@ -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 (<page>:top, <page>: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 <PluginSlot name="sessions:top" /> 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);
|
||||
})();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"path": "/example",
|
||||
"position": "after:skills"
|
||||
},
|
||||
"slots": ["sessions:top"],
|
||||
"entry": "dist/index.js",
|
||||
"api": "plugin_api.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 ``<PluginSlot name="...">`` 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.
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="analytics:top" />
|
||||
{loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
@@ -409,6 +411,7 @@ export default function AnalyticsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<PluginSlot name="analytics:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
|
||||
<PluginSlot name="chat:top" />
|
||||
{mobileModelToolsPortal}
|
||||
|
||||
{banner && (
|
||||
@@ -732,6 +734,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot name="chat:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="config:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header Bar ═══════════════ */}
|
||||
@@ -505,6 +507,7 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PluginSlot name="config:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="cron:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@@ -346,6 +348,7 @@ export default function CronPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<PluginSlot name="cron:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="docs:top" />
|
||||
<iframe
|
||||
title={t.app.nav.documentation}
|
||||
src={HERMES_DOCS_URL}
|
||||
@@ -49,6 +51,7 @@ export default function DocsPage() {
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<PluginSlot name="docs:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider grouping */
|
||||
@@ -511,6 +512,7 @@ export default function EnvPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="env:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@@ -610,6 +612,7 @@ export default function EnvPage() {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<PluginSlot name="env:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { FilterGroup, Segmented } from "@/components/ui/segmented";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
@@ -141,6 +142,7 @@ export default function LogsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="logs:top" />
|
||||
{/* ═══════════════ Filter toolbar ═══════════════ */}
|
||||
<div
|
||||
role="toolbar"
|
||||
@@ -215,6 +217,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PluginSlot name="logs:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||
@@ -612,6 +613,7 @@ export default function SessionsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="sessions:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@@ -834,6 +836,7 @@ export default function SessionsPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PluginSlot name="sessions:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types & helpers */
|
||||
@@ -251,6 +252,7 @@ export default function SkillsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="skills:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Filter panel + Content ═══════════════ */}
|
||||
@@ -509,6 +511,7 @@ export default function SkillsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PluginSlot name="skills:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, { Fragment, useEffect, useState } from "react";
|
||||
/** Slot locations the built-in shell renders. Plugins declaring any of
|
||||
* these in their manifest's `slots` field get wired in automatically.
|
||||
*
|
||||
* Shell-wide slots:
|
||||
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
|
||||
* - `header-left` — injected before the Hermes brand in the top bar
|
||||
* - `header-right` — injected before the theme/language switchers
|
||||
@@ -31,8 +32,31 @@ import React, { Fragment, useEffect, useState } from "react";
|
||||
* - `overlay` — fixed-position layer above everything else;
|
||||
* useful for chrome (scanlines, vignettes) the
|
||||
* theme's customCSS can't achieve alone
|
||||
*
|
||||
* Page-scoped slots (rendered inside a specific built-in page — use these
|
||||
* to inject widgets, cards, or toolbars into existing pages without
|
||||
* overriding the whole route):
|
||||
* - `sessions:top` — top of /sessions page (above session list)
|
||||
* - `sessions:bottom` — bottom of /sessions page
|
||||
* - `analytics:top` — top of /analytics page
|
||||
* - `analytics:bottom` — bottom of /analytics page
|
||||
* - `logs:top` — top of /logs page (above filter toolbar)
|
||||
* - `logs:bottom` — bottom of /logs page (below log viewer)
|
||||
* - `cron:top` — top of /cron page
|
||||
* - `cron:bottom` — bottom of /cron page
|
||||
* - `skills:top` — top of /skills page
|
||||
* - `skills:bottom` — bottom of /skills page
|
||||
* - `config:top` — top of /config page
|
||||
* - `config:bottom` — bottom of /config page
|
||||
* - `env:top` — top of /env (Keys) page
|
||||
* - `env:bottom` — bottom of /env (Keys) page
|
||||
* - `docs:top` — top of /docs page (above the docs iframe)
|
||||
* - `docs:bottom` — bottom of /docs page
|
||||
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
|
||||
* - `chat:bottom` — bottom of /chat page
|
||||
*/
|
||||
export const KNOWN_SLOT_NAMES = [
|
||||
// Shell-wide
|
||||
"backdrop",
|
||||
"header-left",
|
||||
"header-right",
|
||||
@@ -43,6 +67,25 @@ export const KNOWN_SLOT_NAMES = [
|
||||
"footer-left",
|
||||
"footer-right",
|
||||
"overlay",
|
||||
// Page-scoped
|
||||
"sessions:top",
|
||||
"sessions:bottom",
|
||||
"analytics:top",
|
||||
"analytics:bottom",
|
||||
"logs:top",
|
||||
"logs:bottom",
|
||||
"cron:top",
|
||||
"cron:bottom",
|
||||
"skills:top",
|
||||
"skills:bottom",
|
||||
"config:top",
|
||||
"config:bottom",
|
||||
"env:top",
|
||||
"env:bottom",
|
||||
"docs:top",
|
||||
"docs:bottom",
|
||||
"chat:top",
|
||||
"chat:bottom",
|
||||
] as const;
|
||||
|
||||
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
|
||||
|
||||
@@ -552,6 +552,8 @@ window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
|
||||
|
||||
#### Slot catalogue
|
||||
|
||||
**Shell-wide slots** (render anywhere in the app chrome):
|
||||
|
||||
| Slot | Location |
|
||||
|------|----------|
|
||||
| `backdrop` | Inside the `<Backdrop />` layer stack, above the noise layer. |
|
||||
@@ -565,6 +567,35 @@ window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
|
||||
| `footer-right` | Footer cell content (replaces default). |
|
||||
| `overlay` | Fixed-position layer above everything else. Useful for chrome (scanlines, vignettes) `customCSS` can't achieve alone. |
|
||||
|
||||
**Page-scoped slots** (render only on the named built-in page — use these to inject widgets, cards, or toolbars into an existing page without overriding the whole route):
|
||||
|
||||
| Slot | Where it renders |
|
||||
|------|------------------|
|
||||
| `sessions:top` / `sessions:bottom` | Top / bottom of the `/sessions` page. |
|
||||
| `analytics:top` / `analytics:bottom` | Top / bottom of the `/analytics` page. |
|
||||
| `logs:top` / `logs:bottom` | Top (above filter toolbar) / bottom (below log viewer) of `/logs`. |
|
||||
| `cron:top` / `cron:bottom` | Top / bottom of the `/cron` page. |
|
||||
| `skills:top` / `skills:bottom` | Top / bottom of the `/skills` page. |
|
||||
| `config:top` / `config:bottom` | Top / bottom of the `/config` page. |
|
||||
| `env:top` / `env:bottom` | Top / bottom of the `/env` (Keys) page. |
|
||||
| `docs:top` / `docs:bottom` | Top (above the iframe) / bottom of `/docs`. |
|
||||
| `chat:top` / `chat:bottom` | Top / bottom of `/chat` (only active when embedded chat is enabled). |
|
||||
|
||||
Example — add a banner card to the top of the Sessions page:
|
||||
|
||||
```javascript
|
||||
function PinnedSessionsBanner() {
|
||||
return React.createElement(Card, null,
|
||||
React.createElement(CardContent, { className: "py-2 text-xs" },
|
||||
"Pinned note injected by my-plugin"),
|
||||
);
|
||||
}
|
||||
|
||||
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sessions:top", PinnedSessionsBanner);
|
||||
```
|
||||
|
||||
Combine page-scoped slots with `tab.hidden: true` if your plugin only augments existing pages and doesn't need a sidebar tab of its own.
|
||||
|
||||
The shell only renders `<PluginSlot name="..." />` for the slots above. Additional names are accepted by the registry for nested plugin UIs — a plugin can expose its own slots via `SDK.components.PluginSlot`.
|
||||
|
||||
#### Re-registration and HMR
|
||||
|
||||
Reference in New Issue
Block a user