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, Cpu, Database, Download, Eye, FileText, Globe, Heart, KeyRound, Menu, MessageSquare, Package, Puzzle, RotateCw, Settings, Shield, Sparkles, Star, Terminal, Users, Wrench, X, Zap, } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Typography } from "@/components/NouiTypography"; 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 ModelsPage from "@/pages/ModelsPage"; import CronPage from "@/pages/CronPage"; import ProfilesPage from "@/pages/ProfilesPage"; 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, "/models": ModelsPage, "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, "/profiles": ProfilesPage, "/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: "/models", labelKey: "models", label: "Models", icon: Cpu, }, { 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: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users }, { 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, Cpu, FileText, KeyRound, MessageSquare, Package, Settings, Puzzle, Sparkles, Terminal, Globe, Database, Shield, Users, 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 ( setMobileOpen(true)} aria-label={t.app.openNavigation} aria-expanded={mobileOpen} aria-controls="app-sidebar" className="text-midground/70 hover:text-midground" > {t.app.brand} {mobileOpen && ( )} {routes.map(({ key, path, element }) => ( ))} } /> {embeddedChat && !chatOverriddenByPlugin && (pluginsLoading ? ( 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 ( handleClick(action)} disabled={disabled} aria-busy={busy} active={busy} className={cn( "gap-3 px-5 py-1.5 whitespace-nowrap", "font-mondwest text-[0.75rem] tracking-[0.1em]", "transition-opacity", busy ? "text-midground opacity-100" : "opacity-60 hover:opacity-100", "disabled:opacity-30", )} > {isPending ? ( ) : isActionRunning && spin ? ( ) : ( )} {displayLabel} {busy && ( )} ); })} ); } 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; }