diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 46c83d195..522b416e5 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -89,6 +89,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -318,31 +319,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1509,6 +1485,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1519,6 +1496,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1529,6 +1507,7 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1558,6 +1537,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1875,6 +1855,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2210,6 +2191,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2895,6 +2877,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3790,6 +3773,7 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5146,6 +5130,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5245,6 +5230,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6017,6 +6003,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6143,6 +6130,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6252,6 +6240,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6660,6 +6649,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package-lock.json b/web/package-lock.json index c522d8ba0..bc806a371 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@nous-research/ui": "^0.3.0", + "@nous-research/ui": "^0.4.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", @@ -70,6 +70,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1058,9 +1059,9 @@ } }, "node_modules/@nous-research/ui": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.3.0.tgz", - "integrity": "sha512-konGgtV9lkzqYkWuoUGnROqavq1svTnGbERLKItvEXmsRz4xRtbAMHI8rK6sjGpHDpwvOUN3olcOhRLTGuVfcA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.4.0.tgz", + "integrity": "sha512-wA9YImWLFjx3yWsb3TsquwG9VKZunupdovkOjnRboFjNAb3Jcf57o67xWafEPEm3VX6k6RP/+Y9zHWX0PUtZ4w==", "license": "MIT", "dependencies": { "@nanostores/react": "^1.0.0", @@ -1103,6 +1104,7 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", + "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1755,6 +1757,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2489,6 +2492,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2498,6 +2502,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2508,6 +2513,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2572,6 +2578,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2867,6 +2874,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3019,6 +3027,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3526,6 +3535,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3839,6 +3849,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4217,7 +4228,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." + "license": "Standard 'no charge' license: https://gsap.com/standard-license.", + "peer": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -4532,6 +4544,7 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4953,6 +4966,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5080,6 +5094,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5151,6 +5166,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5170,6 +5186,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5532,7 +5549,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -5597,6 +5615,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5682,6 +5701,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5697,6 +5717,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5818,6 +5839,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 8882c5c1c..5ca2288ef 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "@nous-research/ui": "^0.3.0", + "@nous-research/ui": "^0.4.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c6e3c337..3e68cc6c1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,32 +1,57 @@ -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, + useLocation, + useNavigate, +} from "react-router-dom"; import { Activity, BarChart3, + BookOpen, 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 DocsPage from "@/pages/DocsPage"; import EnvPage from "@/pages/EnvPage"; import SessionsPage from "@/pages/SessionsPage"; import LogsPage from "@/pages/LogsPage"; @@ -36,15 +61,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 = { - "/": StatusPage, +function RootRedirect() { + return ; +} + +/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */ +const BUILTIN_ROUTES: Record = { + "/": RootRedirect, "/sessions": SessionsPage, "/analytics": AnalyticsPage, "/logs": LogsPage, @@ -52,10 +79,10 @@ const BUILTIN_ROUTES: Record = { "/skills": SkillsPage, "/config": ConfigPage, "/env": EnvPage, + "/docs": DocsPage, }; const BUILTIN_NAV: NavItem[] = [ - { path: "/", labelKey: "status", label: "Status", icon: Activity }, { path: "/sessions", labelKey: "sessions", @@ -73,11 +100,15 @@ const BUILTIN_NAV: NavItem[] = [ { 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, + }, ]; -// 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> = { +const ICON_MAP: Record> = { Activity, BarChart3, Clock, @@ -100,24 +131,15 @@ const ICON_MAP: Record> = { 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 +167,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(); - const addons: RegisteredPlugin[] = []; +function buildRoutes(manifests: PluginManifest[]): Array<{ + key: string; + path: string; + element: ReactNode; +}> { + const byOverride = new Map(); + 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: , }); } else { - routes.push({ key: `builtin:${path}`, path, Component }); + routes.push({ key: `builtin:${path}`, path, element: }); } } - 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: , + }); + } + + 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: , }); } @@ -201,154 +227,125 @@ function buildRoutes( export default function App() { const { t } = useI18n(); - const { plugins } = usePlugins(); + const { pathname } = useLocation(); + const { manifests } = usePlugins(); const { theme } = useTheme(); + const [mobileOpen, setMobileOpen] = useState(false); + const closeMobile = useCallback(() => setMobileOpen(false), []); + const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; 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]"; + + 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 (
- {/* Themes can style backdrop chrome via `componentStyles.backdrop.*` - CSS vars read by . Plugins can also inject full - components into the backdrop layer via the `backdrop` slot — - useful for scanlines, parallax stars, hero artwork, etc. */}
-
- -
- - - - Hermes -
- Agent -
-
+ - {navItems.map(({ path, label, labelKey, icon: Icon }) => ( - - - 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 }) => ( - <> - - - {labelKey - ? ((t.app.nav as Record)[ - labelKey - ] ?? label) - : label} - - - - - {isActive && ( - - )} - - )} - - - ))} -
-
- - - - - - - - {t.app.webUi} - - - -
+ + {t.app.brand} +
- {/* 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 && ( + +
+ + + + + + + +
+
+ + + +
+
+ + + + + +
+ +
+ + {routes.map(({ key, path, element }) => ( + + ))} + } + /> + +
+ +
+
+ -
- - - - {t.app.footer.name} - - } - /> - - - - {t.app.footer.org} - - } - /> - - -
- - {/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render - above everything else. Each plugin is responsible for its own - pointer-events and z-index. */} ); } +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: 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; +} diff --git a/web/src/components/DeleteConfirmDialog.tsx b/web/src/components/DeleteConfirmDialog.tsx new file mode 100644 index 000000000..9e2e82c68 --- /dev/null +++ b/web/src/components/DeleteConfirmDialog.tsx @@ -0,0 +1,40 @@ +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { useI18n } from "@/i18n"; + +export function DeleteConfirmDialog({ + cancelLabel, + confirmLabel, + description, + loading, + onCancel, + onConfirm, + open, + title, +}: DeleteConfirmDialogProps) { + const { t } = useI18n(); + + return ( + + ); +} + +interface DeleteConfirmDialogProps { + cancelLabel?: string; + confirmLabel?: string; + description?: string; + loading: boolean; + onCancel: () => void; + onConfirm: () => void; + open: boolean; + title: string; +} diff --git a/web/src/components/PlatformsCard.tsx b/web/src/components/PlatformsCard.tsx new file mode 100644 index 000000000..c0412e400 --- /dev/null +++ b/web/src/components/PlatformsCard.tsx @@ -0,0 +1,97 @@ +import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react"; +import type { PlatformStatus } from "@/lib/api"; +import { isoTimeAgo } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useI18n } from "@/i18n"; + +export function PlatformsCard({ platforms }: PlatformsCardProps) { + const { t } = useI18n(); + const platformStateBadge: Record< + string, + { variant: "success" | "warning" | "destructive"; label: string } + > = { + connected: { variant: "success", label: t.status.connected }, + disconnected: { variant: "warning", label: t.status.disconnected }, + fatal: { variant: "destructive", label: t.status.error }, + }; + + return ( + + +
+ + + {t.status.connectedPlatforms} + +
+
+ + + {platforms.map(([name, info]) => { + const display = platformStateBadge[info.state] ?? { + variant: "outline" as const, + label: info.state, + }; + const IconComponent = + info.state === "connected" + ? Wifi + : info.state === "fatal" + ? AlertTriangle + : WifiOff; + + return ( +
+
+ + +
+ + {name} + + + {info.error_message && ( + + {info.error_message} + + )} + + {info.updated_at && ( + + {t.status.lastUpdate}: {isoTimeAgo(info.updated_at)} + + )} +
+
+ + + {display.variant === "success" && ( + + )} + {display.label} + +
+ ); + })} +
+
+ ); +} + +interface PlatformsCardProps { + platforms: [string, PlatformStatus][]; +} diff --git a/web/src/components/SidebarFooter.tsx b/web/src/components/SidebarFooter.tsx new file mode 100644 index 000000000..e28623d72 --- /dev/null +++ b/web/src/components/SidebarFooter.tsx @@ -0,0 +1,40 @@ +import { Typography } from "@nous-research/ui"; +import { useSidebarStatus } from "@/hooks/useSidebarStatus"; +import { cn } from "@/lib/utils"; +import { useI18n } from "@/i18n"; + +export function SidebarFooter() { + const status = useSidebarStatus(); + const { t } = useI18n(); + + return ( +
+ + {status?.version != null ? `v${status.version}` : "—"} + + + + {t.app.footer.org} + +
+ ); +} diff --git a/web/src/components/SidebarStatusStrip.tsx b/web/src/components/SidebarStatusStrip.tsx new file mode 100644 index 000000000..b96603cec --- /dev/null +++ b/web/src/components/SidebarStatusStrip.tsx @@ -0,0 +1,70 @@ +import { Link } from "react-router-dom"; +import type { StatusResponse } from "@/lib/api"; +import { useSidebarStatus } from "@/hooks/useSidebarStatus"; +import { cn } from "@/lib/utils"; +import { useI18n } from "@/i18n"; + +/** Gateway + session summary for the System sidebar block (no separate strip chrome). */ +export function SidebarStatusStrip() { + const status = useSidebarStatus(); + const { t } = useI18n(); + + if (status === null) { + return ( +
+
+
+ ); + } + + const gw = gatewayLine(status, t); + const { activeSessionsLabel, gatewayStatusLabel } = t.app; + + return ( + +
+

+ {gatewayStatusLabel}{" "} + {gw.label} +

+ +

+ {activeSessionsLabel}{" "} + + {status.active_sessions} + +

+
+ + ); +} + +function gatewayLine( + status: StatusResponse, + t: ReturnType["t"], +): { label: string; tone: string } { + const g = t.app.gatewayStrip; + const byState: Record = { + running: { label: g.running, tone: "text-success" }, + starting: { label: g.starting, tone: "text-warning" }, + startup_failed: { label: g.failed, tone: "text-destructive" }, + stopped: { label: g.stopped, tone: "text-muted-foreground" }, + }; + if (status.gateway_state && byState[status.gateway_state]) { + return byState[status.gateway_state]; + } + return status.gateway_running + ? { label: g.running, tone: "text-success" } + : { label: g.off, tone: "text-muted-foreground" }; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index b3475bf46..778afc21e 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -11,8 +11,12 @@ import { cn } from "@/lib/utils"; * glow) so users can preview the palette before committing. User-defined * themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in * `BUILTIN_THEMES` render without swatches and apply the default palette. + * + * When placed at the bottom of a container (e.g. the sidebar rail), pass + * `dropUp` so the menu opens above the trigger instead of clipping below + * the viewport. */ -export function ThemeSwitcher() { +export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { const { themeName, availableThemes, setTheme } = useTheme(); const { t } = useI18n(); const [open, setOpen] = useState(false); @@ -73,7 +77,8 @@ export function ThemeSwitcher() { role="listbox" aria-label={t.theme?.title ?? "Theme"} className={cn( - "absolute right-0 top-full mt-1 z-50 min-w-[240px]", + "absolute z-50 min-w-[240px]", + dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1", "border border-current/20 bg-background-base/95 backdrop-blur-sm", "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", )} @@ -166,3 +171,7 @@ function PlaceholderSwatch() { /> ); } + +interface ThemeSwitcherProps { + dropUp?: boolean; +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index f8e10a6cf..8f2f27206 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -1,7 +1,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; -const buttonVariants = cva( +export const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer" + " disabled:pointer-events-none disabled:opacity-50", { diff --git a/web/src/components/ui/confirm-dialog.tsx b/web/src/components/ui/confirm-dialog.tsx new file mode 100644 index 000000000..48e58264f --- /dev/null +++ b/web/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +export function ConfirmDialog({ + cancelLabel = "Cancel", + confirmLabel = "Confirm", + description, + destructive = false, + loading = false, + onCancel, + onConfirm, + open, + title, +}: ConfirmDialogProps) { + const dialogRef = useRef(null); + + // Focus the confirm button when opened; trap ESC to cancel. + useEffect(() => { + if (!open) return; + + const prevActive = document.activeElement as HTMLElement | null; + dialogRef.current + ?.querySelector("[data-confirm]") + ?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + 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; + prevActive?.focus?.(); + }; + }, [open, onCancel]); + + if (!open) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + className={cn( + "fixed inset-0 z-50 flex items-center justify-center", + "bg-black/60 backdrop-blur-sm", + "animate-[fade-in_150ms_ease-out]", + )} + > +
+
+ {destructive && ( +
+ +
+ )} + +
+

+ {title} +

+ + {description && ( +

+ {description} +

+ )} +
+
+ +
+ + +
+
+
, + document.body, + ); +} + +interface ConfirmDialogProps { + cancelLabel?: string; + confirmLabel?: string; + description?: string; + destructive?: boolean; + loading?: boolean; + onCancel: () => void; + onConfirm: () => void; + open: boolean; + title: string; +} diff --git a/web/src/components/ui/segmented.tsx b/web/src/components/ui/segmented.tsx new file mode 100644 index 000000000..eb4346e9e --- /dev/null +++ b/web/src/components/ui/segmented.tsx @@ -0,0 +1,80 @@ +import { cn } from "@/lib/utils"; + +export function Segmented({ + className, + onChange, + options, + size = "sm", + value, +}: SegmentedProps) { + return ( +
+ {options.map((opt) => { + const active = opt.value === value; + + return ( + + ); + })} +
+ ); +} + +export function FilterGroup({ + children, + className, + label, +}: FilterGroupProps) { + return ( +
+ + {label} + + {children} +
+ ); +} + +interface FilterGroupProps { + children: React.ReactNode; + className?: string; + label: string; +} + +interface SegmentedOption { + label: string; + value: T; +} + +interface SegmentedProps { + className?: string; + onChange: (value: T) => void; + options: SegmentedOption[]; + size?: "sm" | "md"; + value: T; +} diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx index fe36c7755..ad2031277 100644 --- a/web/src/components/ui/switch.tsx +++ b/web/src/components/ui/switch.tsx @@ -5,15 +5,18 @@ export function Switch({ onCheckedChange, className, disabled, + id, }: { checked: boolean; onCheckedChange: (v: boolean) => void; className?: string; disabled?: boolean; + id?: string; }) { return ( + ))} +
+ + , + ); + return () => { + setAfterTitle(null); + setEnd(null); + }; + }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]); + useEffect(() => { load(); }, [load]); return (
- {/* Period selector */} -
- {t.analytics.period} - {PERIODS.map((p) => ( - - ))} -
- {loading && !data && (
diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index b72f0dcdb..80cef29e4 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react"; import { Code, Download, @@ -8,7 +8,6 @@ import { Search, Upload, X, - ChevronRight, Settings2, FileText, Settings, @@ -27,6 +26,7 @@ import { MessageCircle, Wrench, FileQuestion, + Filter, } from "lucide-react"; import { api } from "@/lib/api"; import { getNestedValue, setNestedValue } from "@/lib/nested"; @@ -38,6 +38,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -85,6 +86,35 @@ export default function ConfigPage() { const { toast, showToast } = useToast(); const fileInputRef = useRef(null); const { t } = useI18n(); + const { setEnd } = usePageHeader(); + + useLayoutEffect(() => { + if (!config || !schema) { + setEnd(null); + return; + } + setEnd( +
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
, + ); + return () => setEnd(null); + }, [config, schema, searchQuery, setEnd, t.common.search]); function prettyCategoryName(cat: string): string { const key = cat as keyof typeof t.config.categories; @@ -366,62 +396,66 @@ export default function ConfigPage() { ) : ( /* ═══════════════ Form Mode ═══════════════ */ -
- {/* ---- Sidebar — horizontal scroll on mobile, fixed column on sm+ ---- */} -
-
- {/* Search */} -
- - setSearchQuery(e.target.value)} - /> - {searchQuery && ( - - )} -
+
+ {/* ---- Filter panel ---- */} + {/* ---- Content ---- */}
diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 5db9bac41..10fba6913 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; import { H2 } from "@nous-research/ui"; import { api } from "@/lib/api"; import type { CronJob } from "@/lib/api"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@/hooks/useToast"; +import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { Toast } from "@/components/Toast"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -40,17 +42,17 @@ export default function CronPage() { const [deliver, setDeliver] = useState("local"); const [creating, setCreating] = useState(false); - const loadJobs = () => { + const loadJobs = useCallback(() => { api .getCronJobs() .then(setJobs) .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); - }; + }, [showToast, t.common.loading]); useEffect(() => { loadJobs(); - }, []); + }, [loadJobs]); const handleCreate = async () => { if (!prompt.trim() || !schedule.trim()) { @@ -113,18 +115,25 @@ export default function CronPage() { } }; - const handleDelete = async (job: CronJob) => { - try { - await api.deleteCronJob(job.id); - showToast( - `${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, - "success", - ); - loadJobs(); - } catch (e) { - showToast(`${t.status.error}: ${e}`, "error"); - } - }; + const jobDelete = useConfirmDelete({ + onDelete: useCallback( + async (id: string) => { + const job = jobs.find((j) => j.id === id); + try { + await api.deleteCronJob(id); + showToast( + `${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`, + "success", + ); + loadJobs(); + } catch (e) { + showToast(`${t.status.error}: ${e}`, "error"); + throw e; + } + }, + [jobs, loadJobs, showToast, t.common.delete, t.status.error], + ), + }); if (loading) { return ( @@ -134,10 +143,27 @@ export default function CronPage() { ); } + const pendingJob = jobDelete.pendingId + ? jobs.find((j) => j.id === jobDelete.pendingId) + : null; + return (
+ + {/* Create new job form */} @@ -311,7 +337,7 @@ export default function CronPage() { size="icon" title={t.common.delete} aria-label={t.common.delete} - onClick={() => handleDelete(job)} + onClick={() => jobDelete.requestDelete(job.id)} > diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx new file mode 100644 index 000000000..83b0694ec --- /dev/null +++ b/web/src/pages/DocsPage.tsx @@ -0,0 +1,54 @@ +import { useLayoutEffect } from "react"; +import { ExternalLink } from "lucide-react"; +import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/"; + +export default function DocsPage() { + const { t } = useI18n(); + const { setEnd } = usePageHeader(); + + useLayoutEffect(() => { + setEnd( + + + {t.app.openDocumentation} + , + ); + return () => { + setEnd(null); + }; + }, [setEnd, t]); + + return ( +
+