diff --git a/web/src/App.tsx b/web/src/App.tsx index 71a97113c24a..6239885cbaaf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type ComponentType, type ReactNode, @@ -30,6 +31,8 @@ import { KeyRound, Menu, MessageSquare, + PanelLeftClose, + PanelLeftOpen, Package, Puzzle, RotateCw, @@ -75,17 +78,13 @@ import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; -import { api } from "@/lib/api"; function RootRedirect() { return ; } function UnknownRouteFallback({ pluginsLoading }: { pluginsLoading: boolean }) { - if (pluginsLoading) { - // Render nothing during the plugin-load window — a spinner here would just flash. - return null; - } + if (pluginsLoading) return null; return ; } @@ -96,15 +95,6 @@ const CHAT_NAV_ITEM: NavItem = { 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, @@ -120,33 +110,12 @@ const BUILTIN_ROUTES_CORE: Record = { "/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; -} +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: "/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 }, @@ -154,63 +123,33 @@ const BUILTIN_NAV_REST: NavItem[] = [ { 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, - }, + { 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, + 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[] { +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:")) { + 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); @@ -218,19 +157,12 @@ function buildNavItems( 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); - } + } else items.push(pluginItem); } - return items; } -/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */ -function partitionSidebarNav( - builtIn: NavItem[], - manifests: PluginManifest[], -): { coreItems: NavItem[]; pluginItems: NavItem[] } { +function partitionSidebarNav(builtIn: NavItem[], manifests: PluginManifest[]) { const merged = buildNavItems(builtIn, manifests); const builtinPaths = new Set(builtIn.map((i) => i.path)); const coreItems: NavItem[] = []; @@ -242,177 +174,134 @@ function partitionSidebarNav( return { coreItems, pluginItems }; } -function buildRoutes( - builtinRoutes: Record, - manifests: PluginManifest[], -): Array<{ - key: string; - path: string; - element: ReactNode; -}> { +function buildRoutes(builtinRoutes: Record, manifests: PluginManifest[]) { 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); - } + if (m.tab.override) byOverride.set(m.tab.override, m); + else addons.push(m); } - - const routes: Array<{ - key: string; - path: string; - element: ReactNode; - }> = []; - + 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: }); - } + 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 (m.tab.path === "/plugins") continue; if (builtinRoutes[m.tab.path]) continue; - routes.push({ - key: `plugin:${m.name}`, - path: m.tab.path, - element: , - }); + routes.push({ key: `plugin:${m.name}`, path: m.tab.path, element: }); } - for (const m of manifests) { if (!m.tab.hidden) continue; if (m.tab.path === "/plugins") continue; if (builtinRoutes[m.tab.path] || m.tab.override) continue; - routes.push({ - key: `plugin:hidden:${m.name}`, - path: m.tab.path, - element: , - }); + routes.push({ key: `plugin:hidden:${m.name}`, path: m.tab.path, element: }); } - return routes; } +const SW_MIN = 3; +const SW_MAX = 30; +const SW_DEFAULT = 16; + export default function App() { const { t } = useI18n(); const { pathname } = useLocation(); const { manifests, loading: pluginsLoading } = usePlugins(); const { theme } = useTheme(); const [mobileOpen, setMobileOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [sw, setSw] = useState(SW_DEFAULT); + const [hovering, setHovering] = useState(false); + const resizeRef = useRef(null); + const sidebarRef = useRef(null); const closeMobile = useCallback(() => setMobileOpen(false), []); const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; const normalizedPath = pathname.replace(/\/$/, "") || "/"; const isChatRoute = normalizedPath === "/chat"; const embeddedChat = isDashboardEmbeddedChatEnabled(); - // `dashboard.show_token_analytics` gates the Analytics nav item. The - // page itself remains reachable by URL (it renders an explanation when - // the flag is off — see AnalyticsPage), but hiding the nav entry avoids - // surfacing misleading token/cost numbers in the sidebar. Default off. - const [showTokenAnalytics, setShowTokenAnalytics] = useState(false); + const swClamped = Math.max(SW_MIN, Math.min(SW_MAX, sw)); + const sidebarVisible = !collapsed || hovering; + const sidebarTranslate = sidebarVisible ? 0 : -(swClamped + 1); // +1 for border + + // Resize drag useEffect(() => { - api - .getConfig() - .then((cfg) => { - const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; - setShowTokenAnalytics(dash.show_token_analytics === true); - }) - .catch(() => setShowTokenAnalytics(false)); - }, []); + const el = resizeRef.current; + if (!el) return; + let sx = 0, sw0 = 0; + const onDown = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + sx = e.clientX; + sw0 = swClamped; + const onMove = (ev: MouseEvent) => { + const d = ev.clientX - sx; + setSw(Math.max(SW_MIN, Math.min(SW_MAX, sw0 + d / 16))); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }; + el.addEventListener("mousedown", onDown); + return () => el.removeEventListener("mousedown", onDown); + }, [swClamped]); - // 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 } : {}), - }), + () => ({ ...BUILTIN_ROUTES_CORE, ...(embeddedChat ? { "/chat": ChatRouteSink } : {}) }), [embeddedChat], ); - - const builtinNav = useMemo(() => { - const base = embeddedChat - ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] - : BUILTIN_NAV_REST; - return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics"); - }, [embeddedChat, showTokenAnalytics]); - - const sidebarNav = useMemo( - () => partitionSidebarNav(builtinNav, manifests), - [builtinNav, manifests], - ); - const routes = useMemo( - () => buildRoutes(builtinRoutes, manifests), - [builtinRoutes, manifests], + const builtinNav = useMemo( + () => (embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST), + [embeddedChat], ); + const sidebarNav = useMemo(() => partitionSidebarNav(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.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); - }; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMobileOpen(false); }; document.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; + const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; - return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; - }; + return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; }; }, [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); + const h = (e: MediaQueryListEvent) => { if (e.matches) setMobileOpen(false); }; + mql.addEventListener("change", h); + return () => mql.removeEventListener("change", h); }, []); + // Desktop: if sidebar was shown at width W then collapsed, restore W when re-expanding + const sidebarStyle: React.CSSProperties = { + width: `${swClamped}rem`, + minWidth: `${swClamped}rem`, + transform: `translateX(${sidebarTranslate}rem)`, + background: "var(--component-sidebar-background)", + clipPath: "var(--component-sidebar-clip-path)", + borderImage: "var(--component-sidebar-border-image)", + }; + return (
+ {/* ====== DESKTOP HEADER (always visible, z-50) ====== */} +
+ {/* Toggle sidebar button — always visible */} + + + + {t.app.brand} + + +
+ + {/* Right controls */} +
+ + +
+
+ + {/* ====== MOBILE HEADER ====== */}
- )}
-
+
+ + {/* ====== SIDEBAR ====== */} + {/* ====== RESIZE HANDLE ====== */} +
+
+
+ + {/* ====== HOVER EXPAND ZONE (thin strip when collapsed) ====== */} + {collapsed && ( +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + /> + )} + + {/* ====== MAIN CONTENT (reacts to sidebar width) ====== */}
{routes.map(({ key, path, element }) => ( ))} - - } - /> + } /> - {embeddedChat && - !chatOverriddenByPlugin && - (pluginsLoading ? ( - isChatRoute ? ( -
-
- - Loading chat… -
+ {embeddedChat && !chatOverriddenByPlugin && (pluginsLoading ? ( + isChatRoute && ( +
+
+ + Loading chat…
- ) : null - ) : ( -
-
- ))} + ) + ) : ( +
+ +
+ ))}
@@ -654,11 +572,7 @@ export default function App() { function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) { const { path, label, labelKey, icon: Icon } = item; - - const navLabel = labelKey - ? ((t.app.nav as Record)[labelKey] ?? label) - : label; - + const navLabel = labelKey ? ((t.app.nav as Record)[labelKey] ?? label) : label; return (
  • {({ isActive }) => ( <> {navLabel} - - - - {isActive && ( - - )} + + {isActive && } )} @@ -706,61 +607,28 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) { function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) { const { t } = useI18n(); const navigate = useNavigate(); - const { activeAction, isBusy, isRunning, pendingAction, runAction } = - useSystemActions(); - + 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, - }, + { 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} - - +
    + {t.app.system} -
      {items.map(({ action, icon: Icon, label, runningLabel, spin }) => { const isPending = pendingAction === action; - const isActionRunning = - activeAction === action && isRunning && !isPending; + const isActionRunning = activeAction === action && isRunning && !isPending; const busy = isPending || isActionRunning; const displayLabel = isActionRunning ? runningLabel : label; const disabled = isBusy && !busy; - return (
    • void }) { 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", - )} + 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 ? ( + {isPending ? : isActionRunning && spin ? ( ) : ( - + )} - {displayLabel} - - - - {busy && ( - - )} + + {busy && }
    • ); @@ -833,4 +675,4 @@ interface SystemActionItem { label: string; runningLabel: string; spin: boolean; -} +} \ No newline at end of file