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 }