import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode, } from "react"; import { Routes, Route, NavLink, Navigate, useLocation, useNavigate, } from "react-router-dom"; import { Activity, BarChart3, BookOpen, Clock, Code, Database, Download, Eye, FileText, Globe, Heart, KeyRound, Loader2, Menu, MessageSquare, Package, Puzzle, RotateCw, Settings, Shield, Sparkles, Star, Terminal, Wrench, X, Zap, } from "lucide-react"; import { SelectionSwitcher, Typography } from "@nous-research/ui"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; 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 DocsPage from "@/pages/DocsPage"; import EnvPage from "@/pages/EnvPage"; import SessionsPage from "@/pages/SessionsPage"; import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; import CronPage from "@/pages/CronPage"; import SkillsPage from "@/pages/SkillsPage"; import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; function RootRedirect() { return ; } const CHAT_NAV_ITEM: NavItem = { path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal, }; /** * Built-in routes except /chat. Chat is rendered persistently (outside * ) when embedded — see the persistent chat host block rendered * inline near the bottom of this file — so the PTY child, WebSocket, * and xterm instance survive when the user visits another tab and comes * back. A `display:none` toggle hides the terminal without unmounting. * Routing still owns the URL so /chat deep-links, browser back/forward, * and nav highlight keep working. */ const BUILTIN_ROUTES_CORE: Record = { "/": RootRedirect, "/sessions": SessionsPage, "/analytics": AnalyticsPage, "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, "/config": ConfigPage, "/env": EnvPage, "/docs": DocsPage, }; // Route placeholder for /chat. The persistent ChatPage host (rendered // outside when embedded chat is on) paints on top; this empty // element just claims the path so the `*` catch-all redirect doesn't // fire when the user navigates to /chat. function ChatRouteSink() { return null; } const BUILTIN_NAV_REST: NavItem[] = [ { path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare, }, { path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3, }, { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, { path: "/config", labelKey: "config", label: "Config", icon: Settings }, { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, { path: "/docs", labelKey: "documentation", label: "Documentation", icon: BookOpen, }, ]; const ICON_MAP: Record> = { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings, Puzzle, Sparkles, Terminal, Globe, Database, Shield, Wrench, Zap, Heart, Star, Code, Eye, }; function resolveIcon(name: string): ComponentType<{ className?: string }> { return ICON_MAP[name] ?? Puzzle; } function buildNavItems( builtIn: NavItem[], manifests: PluginManifest[], ): NavItem[] { const items = [...builtIn]; for (const manifest of manifests) { if (manifest.tab.override) continue; if (manifest.tab.hidden) continue; const pluginItem: NavItem = { path: manifest.tab.path, label: manifest.label, icon: resolveIcon(manifest.icon), }; const pos = manifest.tab.position ?? "end"; if (pos === "end") { items.push(pluginItem); } else if (pos.startsWith("after:")) { const target = "/" + pos.slice(6); const idx = items.findIndex((i) => i.path === target); items.splice(idx >= 0 ? idx + 1 : items.length, 0, pluginItem); } else if (pos.startsWith("before:")) { const target = "/" + pos.slice(7); const idx = items.findIndex((i) => i.path === target); items.splice(idx >= 0 ? idx : items.length, 0, pluginItem); } else { items.push(pluginItem); } } return items; } function buildRoutes( builtinRoutes: Record, manifests: PluginManifest[], ): Array<{ key: string; path: string; element: ReactNode; }> { const byOverride = new Map(); const addons: PluginManifest[] = []; for (const m of manifests) { if (m.tab.override) { byOverride.set(m.tab.override, m); } else { addons.push(m); } } const routes: Array<{ key: string; path: string; element: ReactNode; }> = []; for (const [path, Component] of Object.entries(builtinRoutes)) { const om = byOverride.get(path); if (om) { routes.push({ key: `override:${om.name}`, path, element: , }); } else { routes.push({ key: `builtin:${path}`, path, element: }); } } for (const m of addons) { if (m.tab.hidden) continue; if (builtinRoutes[m.tab.path]) continue; routes.push({ key: `plugin:${m.name}`, path: m.tab.path, element: , }); } for (const m of manifests) { if (!m.tab.hidden) continue; if (builtinRoutes[m.tab.path] || m.tab.override) continue; routes.push({ key: `plugin:hidden:${m.name}`, path: m.tab.path, element: , }); } return routes; } export default function App() { const { t } = useI18n(); const { pathname } = useLocation(); const { manifests, loading: pluginsLoading } = usePlugins(); const { theme } = useTheme(); const [mobileOpen, setMobileOpen] = useState(false); const closeMobile = useCallback(() => setMobileOpen(false), []); const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; const normalizedPath = pathname.replace(/\/$/, "") || "/"; const isChatRoute = normalizedPath === "/chat"; const embeddedChat = isDashboardEmbeddedChatEnabled(); // A plugin can replace the built-in /chat page via `tab.override: "/chat"` // in its manifest. When one does, `buildRoutes` already swaps the route // element for — but we also have to suppress the // persistent ChatPage host below, or the plugin's page and the built-in // terminal would paint on top of each other. The override is niche // (nothing ships overriding /chat today) but it's an advertised // extension point, so preserve the pre-persistence contract: when a // plugin owns /chat, the built-in chat UI is entirely absent. // // Waiting on `pluginsLoading` is load-bearing: manifests arrive // asynchronously from /api/dashboard/plugins, so on initial render // `chatOverriddenByPlugin` is always false. Without the loading // gate, the persistent host would mount, spawn a PTY, and THEN get // yanked out from under the user when the plugin's manifest resolves // — killing the session mid-paint. Delaying host mount by the // plugin-load window (typically <50ms, worst case 2s safety timeout) // is the cheaper trade-off. const chatOverriddenByPlugin = useMemo( () => manifests.some((m) => m.tab.override === "/chat"), [manifests], ); const builtinRoutes = useMemo( () => ({ ...BUILTIN_ROUTES_CORE, ...(embeddedChat ? { "/chat": ChatRouteSink } : {}), }), [embeddedChat], ); const builtinNav = useMemo( () => embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST, [embeddedChat], ); const navItems = useMemo( () => buildNavItems(builtinNav, manifests), [builtinNav, manifests], ); const routes = useMemo( () => buildRoutes(builtinRoutes, manifests), [builtinRoutes, manifests], ); const pluginTabMeta = useMemo( () => manifests .filter((m) => !m.tab.hidden) .map((m) => ({ path: m.tab.override ?? m.tab.path, label: m.label, })), [manifests], ); const layoutVariant = theme.layoutVariant ?? "standard"; 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 (
{t.app.brand}
{mobileOpen && (
{routes.map(({ key, path, element }) => ( ))} } /> {/* Persistent chat host: always mounted when `hermes dashboard --tui` is active, visibility toggled by route. Keeping the tree alive preserves the xterm instance, its WebSocket, and the PTY child that backs the TUI session — so navigating to another tab and returning lands the user in the same conversation instead of spawning a fresh session. The host sits alongside (not inside one) because React Router unmounts route elements on path change, which is exactly the destructive lifecycle we're avoiding. Trade-off worth knowing about: while hidden, ChatPage still holds a PTY child + WebSocket + xterm instance for the dashboard's full lifetime. The WS keeps delivering bytes and xterm keeps parsing them into a display:none host (cheap — no paint work, but not free). If this becomes a resource problem we can pause `term.write` when !isActive or idle-disconnect after N minutes hidden; neither is shipped today. */} {embeddedChat && !chatOverriddenByPlugin && (pluginsLoading ? ( // Direct /chat deep-link: plugin manifests haven't resolved // yet, so we can't tell if a plugin is going to claim this // route. Show a lightweight placeholder instead of a // blank page. Typical wait is <50ms; worst case is the // 2s plugin-registration safety timeout. isChatRoute ? (
Loading chat…
) : null ) : (
))}
); } 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 (
{t.app.system}
    {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 (
  • ); })}
); } interface NavItem { icon: ComponentType<{ className?: string }>; label: string; labelKey?: string; path: string; } interface SystemActionItem { action: SystemAction; icon: ComponentType<{ className?: string }>; label: string; runningLabel: string; spin: boolean; }