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

@ -1,31 +1,47 @@
import { useMemo } from "react";
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import {
useCallback,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
import {
Activity,
BarChart3,
Clock,
Code,
Database,
Download,
Eye,
FileText,
Globe,
Heart,
KeyRound,
Loader2,
Menu,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
RotateCw,
Settings,
Shield,
Wrench,
Zap,
Heart,
Sparkles,
Star,
Code,
Eye,
Terminal,
Wrench,
X,
Zap,
} from "lucide-react";
import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
import { SelectionSwitcher, Typography } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import StatusPage from "@/pages/StatusPage";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage";
@ -36,15 +52,17 @@ import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import { PluginSlot, usePlugins } from "@/plugins";
import type { RegisteredPlugin } from "@/plugins";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
/** Built-in route default page component. Used both for standard routing
* and for resolving plugin `tab.override` values. Keys must match the
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
"/": StatusPage,
function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
const BUILTIN_ROUTES: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage,
"/analytics": AnalyticsPage,
"/logs": LogsPage,
@ -55,7 +73,6 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
};
const BUILTIN_NAV: NavItem[] = [
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
{
path: "/sessions",
labelKey: "sessions",
@ -75,9 +92,7 @@ const BUILTIN_NAV: NavItem[] = [
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
];
// Plugins can reference any of these by name in their manifest — keeps bundle
// size sane vs. importing the full lucide-react set.
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
Activity,
BarChart3,
Clock,
@ -100,24 +115,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Eye,
};
function resolveIcon(
name: string,
): React.ComponentType<{ className?: string }> {
function resolveIcon(name: string): ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle;
}
function buildNavItems(
builtIn: NavItem[],
plugins: RegisteredPlugin[],
): NavItem[] {
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
const items = [...builtIn];
for (const { manifest } of plugins) {
// Plugins that replace a built-in route don't add a new tab entry —
// they reuse the existing tab. The nav just lights up the original
// built-in entry when the user visits `/`.
for (const manifest of manifests) {
if (manifest.tab.override) continue;
// Hidden plugins register their component + slots but skip the nav.
if (manifest.tab.hidden) continue;
const pluginItem: NavItem = {
@ -145,54 +151,58 @@ function buildNavItems(
return items;
}
/** Build the final route table, letting plugins override built-in pages.
*
* Returns (path, Component, key) tuples. Plugins with `tab.override`
* win over both built-ins and other plugins (last registration wins if
* two plugins claim the same override, but we warn in dev). Plugins with
* a regular `tab.path` register alongside built-ins as standalone
* routes. */
function buildRoutes(
plugins: RegisteredPlugin[],
): Array<{ key: string; path: string; Component: React.ComponentType }> {
const overrides = new Map<string, RegisteredPlugin>();
const addons: RegisteredPlugin[] = [];
function buildRoutes(manifests: PluginManifest[]): Array<{
key: string;
path: string;
element: ReactNode;
}> {
const byOverride = new Map<string, PluginManifest>();
const addons: PluginManifest[] = [];
for (const p of plugins) {
if (p.manifest.tab.override) {
overrides.set(p.manifest.tab.override, p);
for (const m of manifests) {
if (m.tab.override) {
byOverride.set(m.tab.override, m);
} else {
addons.push(p);
addons.push(m);
}
}
const routes: Array<{
key: string;
path: string;
Component: React.ComponentType;
element: ReactNode;
}> = [];
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
const override = overrides.get(path);
if (override) {
const om = byOverride.get(path);
if (om) {
routes.push({
key: `override:${override.manifest.name}`,
key: `override:${om.name}`,
path,
Component: override.component,
element: <PluginPage name={om.name} />,
});
} else {
routes.push({ key: `builtin:${path}`, path, Component });
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
}
}
for (const addon of addons) {
// Don't double-register a plugin that shadows a built-in path via
// `tab.path` — `override` is the supported mechanism for that.
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
for (const m of addons) {
if (m.tab.hidden) continue;
if (BUILTIN_ROUTES[m.tab.path]) continue;
routes.push({
key: `plugin:${addon.manifest.name}`,
path: addon.manifest.tab.path,
Component: addon.component,
key: `plugin:${m.name}`,
path: m.tab.path,
element: <PluginPage name={m.name} />,
});
}
for (const m of manifests) {
if (!m.tab.hidden) continue;
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
routes.push({
key: `plugin:hidden:${m.name}`,
path: m.tab.path,
element: <PluginPage name={m.name} />,
});
}
@ -201,154 +211,125 @@ function buildRoutes(
export default function App() {
const { t } = useI18n();
const { plugins } = usePlugins();
const { manifests } = usePlugins();
const { theme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, plugins),
[plugins],
() => buildNavItems(BUILTIN_NAV, manifests),
[manifests],
);
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
const pluginTabMeta = useMemo(
() =>
manifests
.filter((m) => !m.tab.hidden)
.map((m) => ({
path: m.tab.override ?? m.tab.path,
label: m.label,
})),
[manifests],
);
const routes = useMemo(() => buildRoutes(plugins), [plugins]);
const layoutVariant = theme.layoutVariant ?? "standard";
const showSidebar = layoutVariant === "cockpit";
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
// standard + cockpit keep the centered reading width.
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
const mainMaxWidth =
layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMobileOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [mobileOpen]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setMobileOpen(false);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return (
<div
data-layout-variant={layoutVariant}
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
>
<SelectionSwitcher />
<Backdrop />
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
CSS vars read by <Backdrop />. Plugins can also inject full
components into the backdrop layer via the `backdrop` slot
useful for scanlines, parallax stars, hero artwork, etc. */}
<PluginSlot name="backdrop" />
<header
className={cn(
"fixed top-0 left-0 right-0 z-40",
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
"flex items-center gap-2 px-3",
"border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm",
)}
style={{
// Themes can tweak header chrome (background, border-image,
// clip-path) via these CSS vars. Unset vars compute to the
// property's initial value, so themes opt in per-property.
background: "var(--component-header-background)",
borderImage: "var(--component-header-border-image)",
clipPath: "var(--component-header-clip-path)",
}}
>
<div className={cn("mx-auto flex h-12", mainMaxWidth)}>
<PluginSlot name="header-left" />
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<Grid
className="h-full !border-t-0 !border-b-0"
style={{
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
}}
>
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
<Typography
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
</Cell>
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-label={t.app.openNavigation}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
className={cn(
"inline-flex h-8 w-8 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
>
<Menu className="h-4 w-4" />
</button>
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
<Cell key={path} className="relative !p-0">
<NavLink
to={path}
end={path === "/"}
className={({ isActive }) =>
cn(
"group relative flex h-full w-full items-center gap-1.5",
"px-2.5 sm:px-4 py-2",
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">
{labelKey
? ((t.app.nav as Record<string, string>)[
labelKey
] ?? label)
: label}
</span>
<span
aria-hidden
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</Cell>
))}
</Grid>
</div>
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
<PluginSlot name="header-right" />
<ThemeSwitcher />
<LanguageSwitcher />
<Typography
mondwest
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
>
{t.app.webUi}
</Typography>
</Cell>
</Grid>
</div>
<Typography
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.brand}
</Typography>
</header>
{/* Full-width banner slot under the nav, outside the main clamp
useful for marquee/alert/status strips themes want to show
above page content. */}
{mobileOpen && (
<button
type="button"
aria-label={t.app.closeNavigation}
onClick={closeMobile}
className={cn(
"lg:hidden fixed inset-0 z-40",
"bg-black/60 backdrop-blur-sm cursor-pointer",
)}
/>
)}
<PluginSlot name="header-banner" />
<div
className={cn(
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
mainMaxWidth,
showSidebar && "flex gap-4 sm:gap-6",
)}
>
{showSidebar && (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
<aside
id="app-sidebar"
aria-label={t.app.navigation}
className={cn(
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
"hidden lg:block",
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
"border-r border-current/20",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
)}
style={{
background: "var(--component-sidebar-background)",
@ -356,75 +337,305 @@ export default function App() {
borderImage: "var(--component-sidebar-border-image)",
}}
>
<PluginSlot
name="sidebar"
fallback={
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
{/* Cockpit layout with no sidebar plugin rare but valid;
the space still exists so the grid doesn't shift when
a plugin loads asynchronously. */}
sidebar slot empty
</div>
}
/>
</aside>
)}
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 px-5",
"border-b border-current/20",
)}
>
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
<main className="min-w-0 flex-1">
<PluginSlot name="pre-main" />
<Routes>
{routes.map(({ key, path, Component }) => (
<Route key={key} path={path} element={<Component />} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<PluginSlot name="post-main" />
</main>
<button
type="button"
onClick={closeMobile}
aria-label={t.app.closeNavigation}
className={cn(
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="shrink-0 border-b border-current/10">
<PluginSlot name="header-left" />
</div>
<nav
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-2"
aria-label={t.app.navigation}
>
<ul className="flex flex-col">
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
<li key={path}>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "opacity-60 hover:opacity-100",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{labelKey
? ((t.app.nav as Record<string, string>)[
labelKey
] ?? label)
: label}
</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-3 py-2",
"border-t border-current/20",
)}
>
<div className="flex min-w-0 items-center gap-2">
<PluginSlot name="header-right" />
<ThemeSwitcher dropUp />
<LanguageSwitcher />
</div>
</div>
<div
className={cn(
"shrink-0 flex items-center justify-between gap-2",
"border-t border-current/20",
"px-5 py-3",
)}
>
<PluginSlot
name="footer-left"
fallback={
<Typography
mondwest
className="text-[0.7rem] tracking-[0.12em] opacity-60"
>
{t.app.brand}
</Typography>
}
/>
<PluginSlot
name="footer-right"
fallback={
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
}
/>
</div>
<SidebarFooter />
</aside>
<div className="hidden shrink-0 lg:block lg:w-64" aria-hidden />
<PageHeaderProvider pluginTabs={pluginTabMeta}>
<main
className={cn(
"relative z-2 min-w-0 min-h-0 flex-1",
"overflow-y-auto",
"px-3 pb-4 sm:px-6 sm:pb-8",
"pt-2 sm:pt-4 lg:pt-6",
)}
>
<PluginSlot name="pre-main" />
<div className={cn("mx-auto w-full", mainMaxWidth)}>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={<Navigate to="/sessions" replace />}
/>
</Routes>
</div>
<PluginSlot name="post-main" />
</main>
</PageHeaderProvider>
</div>
</div>
<footer className="relative z-2 border-t border-current/20">
<Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
<PluginSlot
name="footer-left"
fallback={
<Typography
mondwest
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
>
{t.app.footer.name}
</Typography>
}
/>
</Cell>
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
<PluginSlot
name="footer-right"
fallback={
<Typography
mondwest
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
}
/>
</Cell>
</Grid>
</footer>
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
above everything else. Each plugin is responsible for its own
pointer-events and z-index. */}
<PluginSlot name="overlay" />
</div>
);
}
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
const { t } = useI18n();
const navigate = useNavigate();
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
useSystemActions();
const items: SystemActionItem[] = [
{
action: "restart",
icon: RotateCw,
label: t.status.restartGateway,
runningLabel: t.status.restartingGateway,
spin: true,
},
{
action: "update",
icon: Download,
label: t.status.updateHermes,
runningLabel: t.status.updatingHermes,
spin: false,
},
];
const handleClick = (action: SystemAction) => {
if (isBusy) return;
void runAction(action);
navigate("/sessions");
onNavigate();
};
return (
<div
className={cn(
"shrink-0 flex flex-col",
"border-t border-current/10",
"py-1",
)}
>
<span
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
>
{t.app.system}
</span>
<SidebarStatusStrip />
<ul className="flex flex-col">
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
const isPending = pendingAction === action;
const isActionRunning =
activeAction === action && isRunning && !isPending;
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
const disabled = isBusy && !busy;
return (
<li key={action}>
<button
type="button"
onClick={() => handleClick(action)}
disabled={disabled}
aria-busy={busy}
className={cn(
"group relative flex w-full items-center gap-3",
"px-5 py-1.5",
"font-mondwest text-[0.75rem] tracking-[0.1em]",
"text-left whitespace-nowrap transition-opacity cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
busy
? "text-midground opacity-100"
: "opacity-60 hover:opacity-100",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && spin && "animate-spin",
isActionRunning && !spin && "animate-pulse",
)}
/>
)}
<span className="truncate">{displayLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
</li>
);
})}
</ul>
</div>
);
}
interface NavItem {
icon: React.ComponentType<{ className?: string }>;
icon: ComponentType<{ className?: string }>;
label: string;
labelKey?: string;
path: string;
}
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;
label: string;
runningLabel: string;
spin: boolean;
}