File size: 7,046 Bytes
674d56a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
 * Plugin slot registry.
 *
 * Plugins can inject components into named locations in the app shell
 * (header-left, sidebar, backdrop, etc.) by calling
 * `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
 * from their JS bundle. Multiple plugins can populate the same slot β€” they
 * render stacked in registration order.
 *
 * The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
 * registry accepts any string so plugin ecosystems can define their own
 * slots; the shell only renders `<PluginSlot name="..." />` for the slots
 * it knows about.
 */

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`         β€” optional full-viewport background decoration;
 *                         mounted behind shell chrome at z-0
 *  - `header-left`      β€” injected before the Hermes brand in the top bar
 *  - `header-right`     β€” injected before the theme/language switchers
 *  - `header-banner`    β€” injected below the top nav bar, full-width
 *  - `sidebar`          β€” the cockpit sidebar rail (only rendered when
 *                         `layoutVariant === "cockpit"`)
 *  - `pre-main`         β€” rendered above the route outlet (inside `<main>`)
 *  - `post-main`        β€” rendered below the route outlet (inside `<main>`)
 *  - `footer-left`      β€” replaces the left footer cell content
 *  - `footer-right`     β€” replaces the right footer cell content
 *  - `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
 *  - `plugins:top`       β€” top of /plugins page
 *  - `plugins:bottom`    β€” bottom of /plugins 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",
  "header-banner",
  "sidebar",
  "pre-main",
  "post-main",
  "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",
  "plugins:top",
  "plugins: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];

type SlotListener = () => void;

interface SlotEntry {
  plugin: string;
  component: React.ComponentType;
}

/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
const _slotListeners: Set<SlotListener> = new Set();

function _notifySlots() {
  for (const fn of _slotListeners) {
    try {
      fn();
    } catch {
      /* ignore */
    }
  }
}

/** Register a component for a slot. Called by plugin bundles via
 *  `window.__HERMES_PLUGINS__.registerSlot(...)`.
 *
 *  If the same (plugin, slot) pair is registered twice, the later call
 *  replaces the earlier one β€” this matches how React HMR expects plugin
 *  re-mounts to behave. */
export function registerSlot(
  plugin: string,
  slot: string,
  component: React.ComponentType,
): void {
  const existing = _slotRegistry.get(slot) ?? [];
  const filtered = existing.filter((e) => e.plugin !== plugin);
  filtered.push({ plugin, component });
  _slotRegistry.set(slot, filtered);
  _notifySlots();
}

/** Read current entries for a slot. Returns a copy so callers can't mutate
 *  registry state. */
export function getSlotEntries(slot: string): SlotEntry[] {
  return (_slotRegistry.get(slot) ?? []).slice();
}

/** Subscribe to registry changes. Returns an unsubscribe function. */
export function onSlotRegistered(fn: SlotListener): () => void {
  _slotListeners.add(fn);
  return () => {
    _slotListeners.delete(fn);
  };
}

/** Clear a specific plugin's slot registrations. Useful for HMR /
 *  plugin reload flows β€” not wired in by default. */
export function unregisterPluginSlots(plugin: string): void {
  let changed = false;
  for (const [slot, entries] of _slotRegistry.entries()) {
    const kept = entries.filter((e) => e.plugin !== plugin);
    if (kept.length !== entries.length) {
      changed = true;
      if (kept.length === 0) _slotRegistry.delete(slot);
      else _slotRegistry.set(slot, kept);
    }
  }
  if (changed) _notifySlots();
}

interface PluginSlotProps {
  /** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
  name: string;
  /** Optional content rendered when no plugins have claimed the slot.
   *  Useful for built-in defaults the plugin would replace. */
  fallback?: React.ReactNode;
}

/** Render all components registered for a given slot, stacked in order.
 *
 *  Component re-renders when the slot registry changes so plugins that
 *  arrive after initial mount show up without a manual refresh. */
export function PluginSlot({ name, fallback }: PluginSlotProps) {
  const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));

  useEffect(() => {
    // Pick up anything registered between the initial `useState` call
    // and the first effect tick, then subscribe for future changes.
    setEntries(getSlotEntries(name));
    const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
    return unsub;
  }, [name]);

  if (entries.length === 0) {
    return fallback ? React.createElement(Fragment, null, fallback) : null;
  }

  return React.createElement(
    Fragment,
    null,
    ...entries.map((entry) =>
      React.createElement(entry.component, { key: entry.plugin }),
    ),
  );
}