feat: add sidebar

This commit is contained in:
Austin Pickett
2026-04-22 23:25:17 -04:00
parent 7db2703b33
commit e5d2815b41
41 changed files with 2469 additions and 1391 deletions

View File

@@ -0,0 +1,64 @@
import { useSyncExternalStore } from "react";
import { Loader2 } from "lucide-react";
import {
getPluginComponent,
getPluginLoadError,
onPluginRegistered,
} from "./registry";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
import type { Translations } from "@/i18n/types";
/** Renders a plugin tab once its bundle has called `register()`. */
export function PluginPage({ name }: { name: string }) {
const { t } = useI18n();
// Subscribe in render (via useSyncExternalStore) so we never miss
// `register()` if the script loads before a useEffect would run.
const Component = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginComponent(name) ?? null,
() => null,
);
const loadError = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginLoadError(name) ?? null,
() => null,
);
if (Component) {
return <Component />;
}
if (loadError) {
const message = formatPluginError(loadError, t);
return (
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
)}
role="alert"
>
{message}
</div>
);
}
return (
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
)}
>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t.common.loading}</span>
</div>
);
}
function formatPluginError(code: string, t: Translations): string {
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
return code;
}

View File

@@ -1,4 +1,5 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { PluginPage } from "./PluginPage";
export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots";

View File

@@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots";
type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map();
const _loadErrors: Map<string, string> = new Map();
const _listeners: Set<RegistryListener> = new Set();
function _notify() {
@@ -45,8 +46,14 @@ function _notify() {
}
}
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
export function notifyPluginRegistry() {
_notify();
}
/** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) {
_loadErrors.delete(name);
_registered.set(name, component);
_notify();
}
@@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine
return _registered.get(name);
}
export function getPluginLoadError(name: string): string | undefined {
return _loadErrors.get(name);
}
export function setPluginLoadError(name: string, message: string) {
_loadErrors.set(name, message);
_notify();
}
/** Subscribe to registry changes (returns unsubscribe fn). */
export function onPluginRegistered(fn: RegistryListener): () => void {
_listeners.add(fn);

View File

@@ -1,5 +1,7 @@
/** Types for the dashboard plugin system. */
import type { ComponentType } from "react";
export interface PluginManifest {
name: string;
label: string;
@@ -8,21 +10,14 @@ export interface PluginManifest {
version: string;
tab: {
path: string;
position: string; // "end", "after:<tab>", "before:<tab>"
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this
* plugin's component replaces the built-in page at that route rather
* than adding a new tab. Useful for themes that want a custom home
* page without losing the rest of the dashboard. */
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
position?: string;
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
override?: string;
/** When true, the plugin registers its component and slot contributors
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
* plugin that just injects a header crest). */
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
hidden?: boolean;
};
/** Named shell slots this plugin populates. Mirrored by the backend's
* manifest discovery; used purely as a documentation/discovery aid —
* actual slot registration happens when the plugin's JS bundle calls
* `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)`. */
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
slots?: string[];
entry: string;
css?: string | null;
@@ -32,5 +27,5 @@ export interface PluginManifest {
export interface RegisteredPlugin {
manifest: PluginManifest;
component: React.ComponentType;
component: ComponentType;
}

View File

@@ -10,7 +10,12 @@
import { useState, useEffect, useRef } from "react";
import { api } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types";
import { getPluginComponent, onPluginRegistered } from "./registry";
import {
getPluginComponent,
onPluginRegistered,
notifyPluginRegistry,
setPluginLoadError,
} from "./registry";
export function usePlugins() {
const [manifests, setManifests] = useState<PluginManifest[]>([]);
@@ -33,6 +38,8 @@ export function usePlugins() {
useEffect(() => {
if (manifests.length === 0) return;
const injectedScripts: HTMLScriptElement[] = [];
for (const manifest of manifests) {
// Inject CSS if specified.
if (manifest.css) {
@@ -45,23 +52,49 @@ export function usePlugins() {
}
}
// Load JS bundle.
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
if (loadedScripts.current.has(jsUrl)) continue;
loadedScripts.current.add(jsUrl);
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
// in-memory registry while the browser would otherwise never
// re-execute a previously cached <script> URL.
const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script");
script.src = jsUrl;
script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true;
script.onerror = () => {
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
};
document.body.appendChild(script);
injectedScripts.push(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => clearTimeout(timeout);
return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [manifests]);
// Listen for plugin registrations and resolve them against manifests.