/ web / src / plugins / slots.ts
slots.ts
  1  /**
  2   * Plugin slot registry.
  3   *
  4   * Plugins can inject components into named locations in the app shell
  5   * (header-left, sidebar, backdrop, etc.) by calling
  6   * `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
  7   * from their JS bundle. Multiple plugins can populate the same slot — they
  8   * render stacked in registration order.
  9   *
 10   * The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
 11   * registry accepts any string so plugin ecosystems can define their own
 12   * slots; the shell only renders `<PluginSlot name="..." />` for the slots
 13   * it knows about.
 14   */
 15  
 16  import React, { Fragment, useEffect, useState } from "react";
 17  
 18  /** Slot locations the built-in shell renders. Plugins declaring any of
 19   *  these in their manifest's `slots` field get wired in automatically.
 20   *
 21   *  Shell-wide slots:
 22   *  - `backdrop`         — rendered inside `<Backdrop />`, above the noise layer
 23   *  - `header-left`      — injected before the Hermes brand in the top bar
 24   *  - `header-right`     — injected before the theme/language switchers
 25   *  - `header-banner`    — injected below the top nav bar, full-width
 26   *  - `sidebar`          — the cockpit sidebar rail (only rendered when
 27   *                         `layoutVariant === "cockpit"`)
 28   *  - `pre-main`         — rendered above the route outlet (inside `<main>`)
 29   *  - `post-main`        — rendered below the route outlet (inside `<main>`)
 30   *  - `footer-left`      — replaces the left footer cell content
 31   *  - `footer-right`     — replaces the right footer cell content
 32   *  - `overlay`          — fixed-position layer above everything else;
 33   *                         useful for chrome (scanlines, vignettes) the
 34   *                         theme's customCSS can't achieve alone
 35   *
 36   *  Page-scoped slots (rendered inside a specific built-in page — use these
 37   *  to inject widgets, cards, or toolbars into existing pages without
 38   *  overriding the whole route):
 39   *  - `sessions:top`     — top of /sessions page (above session list)
 40   *  - `sessions:bottom`  — bottom of /sessions page
 41   *  - `analytics:top`    — top of /analytics page
 42   *  - `analytics:bottom` — bottom of /analytics page
 43   *  - `logs:top`         — top of /logs page (above filter toolbar)
 44   *  - `logs:bottom`      — bottom of /logs page (below log viewer)
 45   *  - `cron:top`         — top of /cron page
 46   *  - `cron:bottom`      — bottom of /cron page
 47   *  - `skills:top`       — top of /skills page
 48   *  - `skills:bottom`    — bottom of /skills page
 49   *  - `plugins:top`       — top of /plugins page
 50   *  - `plugins:bottom`    — bottom of /plugins page
 51   *  - `config:top`       — top of /config page
 52   *  - `config:bottom`    — bottom of /config page
 53   *  - `env:top`          — top of /env (Keys) page
 54   *  - `env:bottom`       — bottom of /env (Keys) page
 55   *  - `docs:top`         — top of /docs page (above the docs iframe)
 56   *  - `docs:bottom`      — bottom of /docs page
 57   *  - `chat:top`         — top of /chat page (above the composer, when embedded chat is on)
 58   *  - `chat:bottom`      — bottom of /chat page
 59   */
 60  export const KNOWN_SLOT_NAMES = [
 61    // Shell-wide
 62    "backdrop",
 63    "header-left",
 64    "header-right",
 65    "header-banner",
 66    "sidebar",
 67    "pre-main",
 68    "post-main",
 69    "footer-left",
 70    "footer-right",
 71    "overlay",
 72    // Page-scoped
 73    "sessions:top",
 74    "sessions:bottom",
 75    "analytics:top",
 76    "analytics:bottom",
 77    "logs:top",
 78    "logs:bottom",
 79    "cron:top",
 80    "cron:bottom",
 81    "skills:top",
 82    "skills:bottom",
 83    "plugins:top",
 84    "plugins:bottom",
 85    "config:top",
 86    "config:bottom",
 87    "env:top",
 88    "env:bottom",
 89    "docs:top",
 90    "docs:bottom",
 91    "chat:top",
 92    "chat:bottom",
 93  ] as const;
 94  
 95  export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
 96  
 97  type SlotListener = () => void;
 98  
 99  interface SlotEntry {
100    plugin: string;
101    component: React.ComponentType;
102  }
103  
104  /** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
105  const _slotRegistry: Map<string, SlotEntry[]> = new Map();
106  const _slotListeners: Set<SlotListener> = new Set();
107  
108  function _notifySlots() {
109    for (const fn of _slotListeners) {
110      try {
111        fn();
112      } catch {
113        /* ignore */
114      }
115    }
116  }
117  
118  /** Register a component for a slot. Called by plugin bundles via
119   *  `window.__HERMES_PLUGINS__.registerSlot(...)`.
120   *
121   *  If the same (plugin, slot) pair is registered twice, the later call
122   *  replaces the earlier one — this matches how React HMR expects plugin
123   *  re-mounts to behave. */
124  export function registerSlot(
125    plugin: string,
126    slot: string,
127    component: React.ComponentType,
128  ): void {
129    const existing = _slotRegistry.get(slot) ?? [];
130    const filtered = existing.filter((e) => e.plugin !== plugin);
131    filtered.push({ plugin, component });
132    _slotRegistry.set(slot, filtered);
133    _notifySlots();
134  }
135  
136  /** Read current entries for a slot. Returns a copy so callers can't mutate
137   *  registry state. */
138  export function getSlotEntries(slot: string): SlotEntry[] {
139    return (_slotRegistry.get(slot) ?? []).slice();
140  }
141  
142  /** Subscribe to registry changes. Returns an unsubscribe function. */
143  export function onSlotRegistered(fn: SlotListener): () => void {
144    _slotListeners.add(fn);
145    return () => {
146      _slotListeners.delete(fn);
147    };
148  }
149  
150  /** Clear a specific plugin's slot registrations. Useful for HMR /
151   *  plugin reload flows — not wired in by default. */
152  export function unregisterPluginSlots(plugin: string): void {
153    let changed = false;
154    for (const [slot, entries] of _slotRegistry.entries()) {
155      const kept = entries.filter((e) => e.plugin !== plugin);
156      if (kept.length !== entries.length) {
157        changed = true;
158        if (kept.length === 0) _slotRegistry.delete(slot);
159        else _slotRegistry.set(slot, kept);
160      }
161    }
162    if (changed) _notifySlots();
163  }
164  
165  interface PluginSlotProps {
166    /** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
167    name: string;
168    /** Optional content rendered when no plugins have claimed the slot.
169     *  Useful for built-in defaults the plugin would replace. */
170    fallback?: React.ReactNode;
171  }
172  
173  /** Render all components registered for a given slot, stacked in order.
174   *
175   *  Component re-renders when the slot registry changes so plugins that
176   *  arrive after initial mount show up without a manual refresh. */
177  export function PluginSlot({ name, fallback }: PluginSlotProps) {
178    const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
179  
180    useEffect(() => {
181      // Pick up anything registered between the initial `useState` call
182      // and the first effect tick, then subscribe for future changes.
183      setEntries(getSlotEntries(name));
184      const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
185      return unsub;
186    }, [name]);
187  
188    if (entries.length === 0) {
189      return fallback ? React.createElement(Fragment, null, fallback) : null;
190    }
191  
192    return React.createElement(
193      Fragment,
194      null,
195      ...entries.map((entry) =>
196        React.createElement(entry.component, { key: entry.plugin }),
197      ),
198    );
199  }