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..e3f93fda3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = { - "/": 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, @@ -55,7 +73,6 @@ const BUILTIN_ROUTES: Record = { }; 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> = { +const ICON_MAP: Record> = { Activity, BarChart3, Clock, @@ -100,24 +115,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 +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(); - 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 +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 (
- {/* 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 && ( + +
+ +
+ +
+ + + + + +
+
+ + + +
+
+ +
+ + {t.app.brand} + + } + /> + + {t.app.footer.org} + + } + /> +
+ + + + +
+ + +
+ +
+ + {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/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/EnvPage.tsx b/web/src/pages/EnvPage.tsx index 68c4aa679..ebb528a34 100644 --- a/web/src/pages/EnvPage.tsx +++ b/web/src/pages/EnvPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Eye, EyeOff, @@ -16,8 +16,10 @@ import { } from "lucide-react"; import { api } from "@/lib/api"; import type { EnvVarInfo } from "@/lib/api"; -import { useToast } from "@/hooks/useToast"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { Toast } from "@/components/Toast"; +import { useConfirmDelete } from "@/hooks/useConfirmDelete"; +import { useToast } from "@/hooks/useToast"; import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -95,6 +97,7 @@ function EnvVarRow({ onClear, onReveal, onCancelEdit, + clearDialogOpen = false, compact = false, }: { varKey: string; @@ -107,6 +110,7 @@ function EnvVarRow({ onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; + clearDialogOpen?: boolean; compact?: boolean; }) { const { t } = useI18n(); @@ -219,7 +223,7 @@ function EnvVarRow({ {info.is_set && ( @@ -261,6 +265,7 @@ function ProviderGroupCard({ onClear, onReveal, onCancelEdit, + clearDialogOpen = false, }: { group: ProviderGroup; edits: Record; @@ -271,6 +276,7 @@ function ProviderGroupCard({ onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; + clearDialogOpen?: boolean; }) { const [expanded, setExpanded] = useState(false); const { t } = useI18n(); @@ -325,6 +331,7 @@ function ProviderGroupCard({ key={key} varKey={key} info={info} compact edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} + clearDialogOpen={clearDialogOpen} /> ))} {/* Base URLs (secondary) */} @@ -333,6 +340,7 @@ function ProviderGroupCard({ key={key} varKey={key} info={info} compact edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} + clearDialogOpen={clearDialogOpen} /> ))} {/* Anything else */} @@ -341,6 +349,7 @@ function ProviderGroupCard({ key={key} varKey={key} info={info} compact edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} + clearDialogOpen={clearDialogOpen} /> ))}
@@ -390,24 +399,30 @@ export default function EnvPage() { } }; - const handleClear = async (key: string) => { - setSaving(key); - try { - await api.deleteEnvVar(key); - setVars((prev) => - prev - ? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } } - : prev, - ); - setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); - setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); - showToast(`${key} ${t.common.removed}`, "success"); - } catch (e) { - showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error"); - } finally { - setSaving(null); - } - }; + const keyClear = useConfirmDelete({ + onDelete: useCallback( + async (key: string) => { + setSaving(key); + try { + await api.deleteEnvVar(key); + setVars((prev) => + prev + ? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } } + : prev, + ); + setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); + setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); + showToast(`${key} ${t.common.removed}`, "success"); + } catch (e) { + showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error"); + throw e; + } finally { + setSaving(null); + } + }, + [showToast, t.common.removed, t.common.failedToRemove], + ), + }); const handleReveal = async (key: string) => { if (revealed[key]) { @@ -488,10 +503,29 @@ export default function EnvPage() { const totalProviders = providerGroups.length; const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length; + const pendingClearKey = keyClear.pendingId; + const pendingKeyDescription = + pendingClearKey && vars + ? vars[pendingClearKey]?.description + : undefined; + return (
+ +

@@ -530,7 +564,8 @@ export default function EnvPage() { key={group.name} group={group} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} - onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit} + onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit} + clearDialogOpen={keyClear.isOpen} /> ))} @@ -557,7 +592,8 @@ export default function EnvPage() { ))} @@ -566,7 +602,8 @@ export default function EnvPage() { category={category} unsetEntries={unsetEntries} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} - onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit} + onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit} + clearDialogOpen={keyClear.isOpen} /> )} @@ -592,6 +629,7 @@ function CollapsibleUnset({ onClear, onReveal, onCancelEdit, + clearDialogOpen = false, }: { category: string; unsetEntries: [string, EnvVarInfo][]; @@ -603,6 +641,7 @@ function CollapsibleUnset({ onClear: (key: string) => void; onReveal: (key: string) => void; onCancelEdit: (key: string) => void; + clearDialogOpen?: boolean; }) { const [collapsed, setCollapsed] = useState(true); const { t } = useI18n(); @@ -625,6 +664,7 @@ function CollapsibleUnset({ key={key} varKey={key} info={info} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} + clearDialogOpen={clearDialogOpen} /> ))} diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index ec4d7bc16..e376ef29d 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -1,13 +1,14 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { FileText, RefreshCw, ChevronRight } from "lucide-react"; -import { H2 } from "@nous-research/ui"; +import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; +import { FileText, RefreshCw } from "lucide-react"; import { api } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { FilterGroup, Segmented } from "@/components/ui/segmented"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; const FILES = ["agent", "errors", "gateway"] as const; const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const; @@ -34,38 +35,8 @@ const LINE_COLORS: Record = { debug: "text-muted-foreground/60", }; -function SidebarHeading({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function SidebarItem({ - label, - value, - current, - onChange, -}: SidebarItemProps) { - const isActive = current === value; - return ( - - ); -} +const toOptions = (values: readonly T[]) => + values.map((v) => ({ value: v, label: v })); export default function LogsPage() { const [file, setFile] = useState<(typeof FILES)[number]>("agent"); @@ -79,6 +50,7 @@ export default function LogsPage() { const [error, setError] = useState(null); const scrollRef = useRef(null); const { t } = useI18n(); + const { setAfterTitle, setEnd } = usePageHeader(); const fetchLogs = useCallback(() => { setLoading(true); @@ -97,6 +69,66 @@ export default function LogsPage() { .finally(() => setLoading(false)); }, [file, lineCount, level, component]); + useLayoutEffect(() => { + setAfterTitle( + + {loading && ( +

+ )} + + {file} · {level} · {component} + + , + ); + setEnd( +
+
+ + + {autoRefresh && ( + + + {t.common.live} + + )} +
+ +
, + ); + return () => { + setAfterTitle(null); + setEnd(null); + }; + }, [ + autoRefresh, + component, + file, + level, + loading, + setAfterTitle, + setEnd, + t.common.live, + t.common.refresh, + t.logs.autoRefresh, + fetchLogs, + ]); + useEffect(() => { fetchLogs(); }, [fetchLogs]); @@ -109,145 +141,80 @@ export default function LogsPage() { return (
- {/* ═══════════════ Header ═══════════════ */} -
-
- -

{t.logs.title}

- {loading && ( -
- )} - - {file} · {level} · {component} - -
-
-
- - - {autoRefresh && ( - - - {t.common.live} - - )} -
- -
-
- - {/* ═══════════════ Sidebar + Content ═══════════════ */} + {/* ═══════════════ Filter toolbar ═══════════════ */}
- {/* ---- Sidebar ---- */} -
-
- {t.logs.file} - {FILES.map((f) => ( - - ))} + + + - {t.logs.level} - {LEVELS.map((l) => ( - - ))} + + + - {t.logs.component} - {COMPONENTS.map((c) => ( - - ))} + + + - {t.logs.lines} - {LINE_COUNTS.map((n) => ( - - setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) - } - /> - ))} -
-
- - {/* ---- Content ---- */} -
- - - - - {file}.log - - - - {error && ( -
-

{error}

-
- )} - -
- {lines.length === 0 && !loading && ( -

- {t.logs.noLogLines} -

- )} - {lines.map((line, i) => { - const cls = classifyLine(line); - return ( -
- {line} -
- ); - })} -
-
-
-
+ + + setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) + } + options={LINE_COUNTS.map((n) => ({ + value: String(n), + label: String(n), + }))} + /> +
+ + {/* ═══════════════ Log viewer ═══════════════ */} + + + + + {file}.log + + + + {error && ( +
+

{error}

+
+ )} + +
+ {lines.length === 0 && !loading && ( +

+ {t.logs.noLogLines} +

+ )} + {lines.map((line, i) => { + const cls = classifyLine(line); + return ( +
+ {line} +
+ ); + })} +
+
+
); } - -interface SidebarItemProps { - label: string; - value: T; - current: T; - onChange: (v: T) => void; -} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 370b499a8..ad6bb74ce 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1,8 +1,12 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; import { + AlertTriangle, + CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, + Database, + Loader2, MessageSquare, Search, Trash2, @@ -13,19 +17,27 @@ import { Hash, X, } from "lucide-react"; -import { H2 } from "@nous-research/ui"; import { api } from "@/lib/api"; import type { SessionInfo, SessionMessage, SessionSearchResult, + StatusResponse, } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { Markdown } from "@/components/Markdown"; +import { PlatformsCard } from "@/components/PlatformsCard"; +import { Toast } from "@/components/Toast"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { useConfirmDelete } from "@/hooks/useConfirmDelete"; import { Input } from "@/components/ui/input"; +import { useSystemActions } from "@/contexts/useSystemActions"; +import { useToast } from "@/hooks/useToast"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; const SOURCE_CONFIG: Record = { @@ -381,7 +393,62 @@ export default function SessionsPage() { >(null); const [searching, setSearching] = useState(false); const debounceRef = useRef>(null); + const logScrollRef = useRef(null); + const [status, setStatus] = useState(null); + const [overviewSessions, setOverviewSessions] = useState([]); + const { toast, showToast } = useToast(); const { t } = useI18n(); + const { setAfterTitle, setEnd } = usePageHeader(); + const { activeAction, actionStatus, dismissLog } = useSystemActions(); + + useLayoutEffect(() => { + if (loading) { + setAfterTitle(null); + setEnd(null); + return; + } + setAfterTitle( + + {total} + , + ); + setEnd( +
+ {searching ? ( +
+ ) : ( + + )} + setSearch(e.target.value)} + className="h-8 pr-7 pl-8 text-xs" + /> + {search && ( + + )} +
, + ); + return () => { + setAfterTitle(null); + setEnd(null); + }; + }, [ + loading, + search, + searching, + setAfterTitle, + setEnd, + t.sessions.searchPlaceholder, + total, + ]); const loadSessions = useCallback((p: number) => { setLoading(true); @@ -399,6 +466,24 @@ export default function SessionsPage() { loadSessions(page); }, [loadSessions, page]); + useEffect(() => { + const loadOverview = () => { + api.getStatus().then(setStatus).catch(() => {}); + api + .getSessions(50) + .then((r) => setOverviewSessions(r.sessions)) + .catch(() => {}); + }; + loadOverview(); + const id = setInterval(loadOverview, 5000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + const el = logScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [actionStatus?.lines]); + // Debounced FTS search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); @@ -423,16 +508,27 @@ export default function SessionsPage() { }; }, [search]); - const handleDelete = async (id: string) => { - try { - await api.deleteSession(id); - setSessions((prev) => prev.filter((s) => s.id !== id)); - setTotal((prev) => prev - 1); - if (expandedId === id) setExpandedId(null); - } catch { - // ignore - } - }; + const sessionDelete = useConfirmDelete({ + onDelete: useCallback( + async (id: string) => { + try { + await api.deleteSession(id); + setSessions((prev) => prev.filter((s) => s.id !== id)); + setTotal((prev) => prev - 1); + if (expandedId === id) setExpandedId(null); + showToast(t.sessions.sessionDeleted, "success"); + } catch { + showToast(t.sessions.failedToDelete, "error"); + throw new Error("delete failed"); + } + }, + [expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete], + ), + }); + + const pendingSession = sessionDelete.pendingId + ? sessions.find((s) => s.id === sessionDelete.pendingId) + : null; // Build snippet map from search results (session_id → snippet) const snippetMap = new Map(); @@ -448,6 +544,36 @@ export default function SessionsPage() { ? sessions.filter((s) => snippetMap.has(s.id)) : sessions; + const platformEntries = status + ? Object.entries(status.gateway_platforms ?? {}) + : []; + const recentSessions = overviewSessions + .filter((s) => !s.is_active) + .slice(0, 5); + + const alerts: { message: string; detail?: string }[] = []; + if (status) { + if (status.gateway_state === "startup_failed") { + alerts.push({ + message: t.status.gatewayFailedToStart, + detail: status.gateway_exit_reason ?? undefined, + }); + } + const failedPlatformEntries = platformEntries.filter( + ([, info]) => info.state === "fatal" || info.state === "disconnected", + ); + for (const [name, info] of failedPlatformEntries) { + const stateLabel = + info.state === "fatal" + ? t.status.platformError + : t.status.platformDisconnected; + alerts.push({ + message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, + detail: info.error_message ?? undefined, + }); + } + } + if (loading) { return (
@@ -458,38 +584,159 @@ export default function SessionsPage() { return (
- {/* Header outside card for lighter feel */} -
-
- -

{t.sessions.title}

- - {total} - + + + + + {alerts.length > 0 && ( +
+
+ +
+ {alerts.map((alert, i) => ( +
+

+ {alert.message} +

+ {alert.detail && ( +

+ {alert.detail} +

+ )} +
+ ))} +
+
-
- {searching ? ( -
- ) : ( - - )} - setSearch(e.target.value)} - className="pl-8 pr-7 h-8 text-xs" - /> - {search && ( + )} + + {activeAction && ( +
+
+
+ {actionStatus?.running ? ( + + ) : actionStatus?.exit_code === 0 ? ( + + ) : actionStatus !== null ? ( + + ) : ( + + )} + + + {activeAction === "restart" + ? t.status.restartGateway + : t.status.updateHermes} + + + + {actionStatus?.running + ? t.status.running + : actionStatus?.exit_code === 0 + ? t.status.actionFinished + : actionStatus + ? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})` + : t.common.loading} + +
+ - )} +
+ +
+            {actionStatus?.lines && actionStatus.lines.length > 0
+              ? actionStatus.lines.join("\n")
+              : t.status.waitingForOutput}
+          
-
+ )} + + {platformEntries.length > 0 && status && ( + + )} + + {recentSessions.length > 0 && ( + + +
+ + + {t.status.recentSessions} + +
+
+ + + {recentSessions.map((s) => ( +
+
+ + {s.title ?? t.common.untitled} + + + + + {(s.model ?? t.common.unknown).split("/").pop()} + {" "} + · {s.message_count} {t.common.msgs} ·{" "} + {timeAgo(s.last_active)} + + + {s.preview && ( + + {s.preview} + + )} +
+ + + + {s.source ?? "local"} + +
+ ))} +
+
+ )} {filtered.length === 0 ? (
@@ -516,7 +763,7 @@ export default function SessionsPage() { onToggle={() => setExpandedId((prev) => (prev === s.id ? null : s.id)) } - onDelete={() => handleDelete(s.id)} + onDelete={() => sessionDelete.requestDelete(s.id)} /> ))}
diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index a007b3b9e..c951d249e 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -1,9 +1,8 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useLayoutEffect, useState, useMemo } from "react"; import { Package, Search, Wrench, - ChevronRight, X, Cpu, Globe, @@ -14,8 +13,8 @@ import { Blocks, Code, Zap, + Filter, } from "lucide-react"; -import { H2 } from "@nous-research/ui"; import { api } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; @@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; /* ------------------------------------------------------------------ */ /* Types & helpers */ @@ -98,6 +98,7 @@ export default function SkillsPage() { const [togglingSkills, setTogglingSkills] = useState>(new Set()); const { toast, showToast } = useToast(); const { t } = useI18n(); + const { setAfterTitle, setEnd } = usePageHeader(); useEffect(() => { Promise.all([api.getSkills(), api.getToolsets()]) @@ -182,6 +183,53 @@ export default function SkillsPage() { const enabledCount = skills.filter((s) => s.enabled).length; + useLayoutEffect(() => { + if (loading) { + setAfterTitle(null); + setEnd(null); + return; + } + setAfterTitle( + + {t.skills.enabledOf + .replace("{enabled}", String(enabledCount)) + .replace("{total}", String(skills.length))} + , + ); + setEnd( +
+ + setSearch(e.target.value)} + /> + {search && ( + + )} +
, + ); + return () => { + setAfterTitle(null); + setEnd(null); + }; + }, [ + enabledCount, + loading, + search, + setAfterTitle, + setEnd, + skills.length, + t, + ]); + const filteredToolsets = useMemo(() => { return toolsets.filter( (ts) => @@ -205,122 +253,98 @@ export default function SkillsPage() {
- {/* ═══════════════ Header ═══════════════ */} -
-
- -

{t.skills.title}

- - {t.skills.enabledOf - .replace("{enabled}", String(enabledCount)) - .replace("{total}", String(skills.length))} - -
-
+ {/* ═══════════════ Filter panel + Content ═══════════════ */} +
+ {/* ---- Filter panel ---- */} + {/* ---- Content ---- */}
@@ -522,9 +546,39 @@ function SkillRow({ ); } +function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) { + return ( + + ); +} + +interface PanelItemProps { + active: boolean; + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +} + interface SkillRowProps { + noDescriptionLabel: string; + onToggle: () => void; skill: SkillInfo; toggling: boolean; - onToggle: () => void; - noDescriptionLabel: string; } diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx deleted file mode 100644 index 3c213b5cb..000000000 --- a/web/src/pages/StatusPage.tsx +++ /dev/null @@ -1,614 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - Activity, - AlertTriangle, - CheckCircle2, - Clock, - Cpu, - Database, - Download, - Loader2, - Radio, - RotateCw, - Wifi, - WifiOff, - Wrench, - X, -} from "lucide-react"; -import { Cell, Grid } from "@nous-research/ui"; -import { api } from "@/lib/api"; -import type { - ActionStatusResponse, - PlatformStatus, - SessionInfo, - StatusResponse, -} from "@/lib/api"; -import { cn, timeAgo, isoTimeAgo } from "@/lib/utils"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Toast } from "@/components/Toast"; -import { useI18n } from "@/i18n"; - -const ACTION_NAMES: Record<"restart" | "update", string> = { - restart: "gateway-restart", - update: "hermes-update", -}; - -export default function StatusPage() { - const [status, setStatus] = useState(null); - const [sessions, setSessions] = useState([]); - const [pendingAction, setPendingAction] = useState< - "restart" | "update" | null - >(null); - const [activeAction, setActiveAction] = useState<"restart" | "update" | null>( - null, - ); - const [actionStatus, setActionStatus] = useState( - null, - ); - const [toast, setToast] = useState(null); - const logScrollRef = useRef(null); - const { t } = useI18n(); - - useEffect(() => { - const load = () => { - api - .getStatus() - .then(setStatus) - .catch(() => {}); - api - .getSessions(50) - .then((resp) => setSessions(resp.sessions)) - .catch(() => {}); - }; - load(); - const interval = setInterval(load, 5000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (!toast) return; - const timer = setTimeout(() => setToast(null), 4000); - return () => clearTimeout(timer); - }, [toast]); - - useEffect(() => { - if (!activeAction) return; - const name = ACTION_NAMES[activeAction]; - let cancelled = false; - - const poll = async () => { - try { - const resp = await api.getActionStatus(name); - if (cancelled) return; - setActionStatus(resp); - if (!resp.running) { - const ok = resp.exit_code === 0; - setToast({ - type: ok ? "success" : "error", - message: ok - ? t.status.actionFinished - : `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`, - }); - return; - } - } catch { - // transient fetch error; keep polling - } - if (!cancelled) setTimeout(poll, 1500); - }; - - poll(); - return () => { - cancelled = true; - }; - }, [activeAction, t.status.actionFinished, t.status.actionFailed]); - - useEffect(() => { - const el = logScrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [actionStatus?.lines]); - - const runAction = async (action: "restart" | "update") => { - setPendingAction(action); - setActionStatus(null); - try { - if (action === "restart") { - await api.restartGateway(); - } else { - await api.updateHermes(); - } - setActiveAction(action); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - setToast({ - type: "error", - message: `${t.status.actionFailed}: ${detail}`, - }); - } finally { - setPendingAction(null); - } - }; - - const dismissLog = () => { - setActiveAction(null); - setActionStatus(null); - }; - - if (!status) { - return ( -
-
-
- ); - } - - const PLATFORM_STATE_BADGE: 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 }, - }; - - const GATEWAY_STATE_DISPLAY: Record< - string, - { badge: "success" | "warning" | "destructive" | "outline"; label: string } - > = { - running: { badge: "success", label: t.status.running }, - starting: { badge: "warning", label: t.status.starting }, - startup_failed: { badge: "destructive", label: t.status.failed }, - stopped: { badge: "outline", label: t.status.stopped }, - }; - - function gatewayValue(): string { - if (status!.gateway_running && status!.gateway_health_url) - return status!.gateway_health_url; - if (status!.gateway_running && status!.gateway_pid) - return `${t.status.pid} ${status!.gateway_pid}`; - if (status!.gateway_running) return t.status.runningRemote; - if (status!.gateway_state === "startup_failed") return t.status.startFailed; - return t.status.notRunning; - } - - function gatewayBadge() { - const info = status!.gateway_state - ? GATEWAY_STATE_DISPLAY[status!.gateway_state] - : null; - if (info) return info; - return status!.gateway_running - ? { badge: "success" as const, label: t.status.running } - : { badge: "outline" as const, label: t.common.off }; - } - - const gwBadge = gatewayBadge(); - - const items = [ - { - icon: Cpu, - label: t.status.agent, - value: `v${status.version}`, - badgeText: t.common.live, - badgeVariant: "success" as const, - }, - { - icon: Radio, - label: t.status.gateway, - value: gatewayValue(), - badgeText: gwBadge.label, - badgeVariant: gwBadge.badge, - }, - { - icon: Activity, - label: t.status.activeSessions, - value: - status.active_sessions > 0 - ? `${status.active_sessions} ${t.status.running.toLowerCase()}` - : t.status.noneRunning, - badgeText: status.active_sessions > 0 ? t.common.live : t.common.off, - badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as - | "success" - | "outline", - }, - ]; - - const platforms = Object.entries(status.gateway_platforms ?? {}); - const activeSessions = sessions.filter((s) => s.is_active); - const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5); - - // Collect alerts that need attention - const alerts: { message: string; detail?: string }[] = []; - if (status.gateway_state === "startup_failed") { - alerts.push({ - message: t.status.gatewayFailedToStart, - detail: status.gateway_exit_reason ?? undefined, - }); - } - const failedPlatforms = platforms.filter( - ([, info]) => info.state === "fatal" || info.state === "disconnected", - ); - for (const [name, info] of failedPlatforms) { - const stateLabel = - info.state === "fatal" - ? t.status.platformError - : t.status.platformDisconnected; - alerts.push({ - message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, - detail: info.error_message ?? undefined, - }); - } - - return ( -
- - - {alerts.length > 0 && ( -
-
- -
- {alerts.map((alert, i) => ( -
-

- {alert.message} -

- {alert.detail && ( -

- {alert.detail} -

- )} -
- ))} -
-
-
- )} - - - {items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => ( - -
- {label} - -
- -
- {value} -
- - {badgeText && ( - - {badgeVariant === "success" && ( - - )} - {badgeText} - - )} -
- ))} - - -
- - {t.status.actions} - - -
- -
- - - -
-
-
- - {activeAction && ( -
-
-
- {actionStatus?.running ? ( - - ) : actionStatus?.exit_code === 0 ? ( - - ) : actionStatus !== null ? ( - - ) : ( - - )} - - - {activeAction === "restart" - ? t.status.restartGateway - : t.status.updateHermes} - - - - {actionStatus?.running - ? t.status.running - : actionStatus?.exit_code === 0 - ? t.status.actionFinished - : actionStatus - ? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})` - : t.common.loading} - -
- - -
- -
-            {actionStatus?.lines && actionStatus.lines.length > 0
-              ? actionStatus.lines.join("\n")
-              : t.status.waitingForOutput}
-          
-
- )} - - {platforms.length > 0 && ( - - )} - - {activeSessions.length > 0 && ( - - -
- - - {t.status.activeSessions} - -
-
- - - {activeSessions.map((s) => ( -
-
-
- - {s.title ?? t.common.untitled} - - - - - {t.common.live} - -
- - - - {(s.model ?? t.common.unknown).split("/").pop()} - {" "} - · {s.message_count} {t.common.msgs} ·{" "} - {timeAgo(s.last_active)} - -
-
- ))} -
-
- )} - - {recentSessions.length > 0 && ( - - -
- - - {t.status.recentSessions} - -
-
- - - {recentSessions.map((s) => ( -
-
- - {s.title ?? t.common.untitled} - - - - - {(s.model ?? t.common.unknown).split("/").pop()} - {" "} - · {s.message_count} {t.common.msgs} ·{" "} - {timeAgo(s.last_active)} - - - {s.preview && ( - - {s.preview} - - )} -
- - - - {s.source ?? "local"} - -
- ))} -
-
- )} -
- ); -} - -function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) { - const { t } = useI18n(); - - 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 ToastState { - message: string; - type: "success" | "error"; -} - -interface PlatformsCardProps { - platforms: [string, PlatformStatus][]; - platformStateBadge: Record< - string, - { variant: "success" | "warning" | "destructive"; label: string } - >; -} diff --git a/web/src/plugins/PluginPage.tsx b/web/src/plugins/PluginPage.tsx new file mode 100644 index 000000000..4b8f937d6 --- /dev/null +++ b/web/src/plugins/PluginPage.tsx @@ -0,0 +1,64 @@ +import { useSyncExternalStore } from "react"; +import { Loader2 } from "lucide-react"; +import { + getPluginComponent, + getPluginLoadError, + onPluginRegistered, +} from "./registry"; +import { useI18n } from "@/i18n"; +import { cn } from "@/lib/utils"; +import type { Translations } from "@/i18n/types"; + +/** Renders a plugin tab once its bundle has called `register()`. */ +export function PluginPage({ name }: { name: string }) { + const { t } = useI18n(); + // Subscribe in render (via useSyncExternalStore) so we never miss + // `register()` if the script loads before a useEffect would run. + const Component = useSyncExternalStore( + (onChange) => onPluginRegistered(onChange), + () => getPluginComponent(name) ?? null, + () => null, + ); + const loadError = useSyncExternalStore( + (onChange) => onPluginRegistered(onChange), + () => getPluginLoadError(name) ?? null, + () => null, + ); + + if (Component) { + return ; + } + + if (loadError) { + const message = formatPluginError(loadError, t); + return ( +
+ {message} +
+ ); + } + + return ( +
+ + {t.common.loading} +
+ ); +} + +function formatPluginError(code: string, t: Translations): string { + if (code === "LOAD_FAILED") return t.common.pluginLoadFailed; + if (code === "NO_REGISTER") return t.common.pluginNotRegistered; + return code; +} diff --git a/web/src/plugins/index.ts b/web/src/plugins/index.ts index 27902fc93..da9c1bdef 100644 --- a/web/src/plugins/index.ts +++ b/web/src/plugins/index.ts @@ -1,4 +1,5 @@ export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry"; +export { PluginPage } from "./PluginPage"; export { usePlugins } from "./usePlugins"; export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots"; export type { KnownSlotName } from "./slots"; diff --git a/web/src/plugins/registry.ts b/web/src/plugins/registry.ts index fec230c2e..08a5c9990 100644 --- a/web/src/plugins/registry.ts +++ b/web/src/plugins/registry.ts @@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots"; type RegistryListener = () => void; const _registered: Map = new Map(); +const _loadErrors: Map = new Map(); const _listeners: Set = new Set(); function _notify() { @@ -45,8 +46,14 @@ function _notify() { } } +/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */ +export function notifyPluginRegistry() { + _notify(); +} + /** Register a plugin component. Called by plugin JS bundles. */ function registerPlugin(name: string, component: React.ComponentType) { + _loadErrors.delete(name); _registered.set(name, component); _notify(); } @@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine return _registered.get(name); } +export function getPluginLoadError(name: string): string | undefined { + return _loadErrors.get(name); +} + +export function setPluginLoadError(name: string, message: string) { + _loadErrors.set(name, message); + _notify(); +} + /** Subscribe to registry changes (returns unsubscribe fn). */ export function onPluginRegistered(fn: RegistryListener): () => void { _listeners.add(fn); diff --git a/web/src/plugins/types.ts b/web/src/plugins/types.ts index 6b56d3279..dd11c35c2 100644 --- a/web/src/plugins/types.ts +++ b/web/src/plugins/types.ts @@ -1,5 +1,7 @@ /** Types for the dashboard plugin system. */ +import type { ComponentType } from "react"; + export interface PluginManifest { name: string; label: string; @@ -8,21 +10,14 @@ export interface PluginManifest { version: string; tab: { path: string; - position: string; // "end", "after:", "before:" - /** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this - * plugin's component replaces the built-in page at that route rather - * than adding a new tab. Useful for themes that want a custom home - * page without losing the rest of the dashboard. */ + /** "end", "after:", "before:" (e.g. "after:skills" → after `/skills`) */ + position?: string; + /** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */ override?: string; - /** When true, the plugin registers its component and slot contributors - * without adding a tab to the nav. Used by slot-only plugins (e.g. a - * plugin that just injects a header crest). */ + /** When true, the plugin may register without a sidebar tab (slot-only, etc.). */ hidden?: boolean; }; - /** Named shell slots this plugin populates. Mirrored by the backend's - * manifest discovery; used purely as a documentation/discovery aid — - * actual slot registration happens when the plugin's JS bundle calls - * `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)`. */ + /** Declared for discovery; actual slots use registerSlot in the plugin bundle. */ slots?: string[]; entry: string; css?: string | null; @@ -32,5 +27,5 @@ export interface PluginManifest { export interface RegisteredPlugin { manifest: PluginManifest; - component: React.ComponentType; + component: ComponentType; } diff --git a/web/src/plugins/usePlugins.ts b/web/src/plugins/usePlugins.ts index 79b38f336..147b1f0a8 100644 --- a/web/src/plugins/usePlugins.ts +++ b/web/src/plugins/usePlugins.ts @@ -10,7 +10,12 @@ import { useState, useEffect, useRef } from "react"; import { api } from "@/lib/api"; import type { PluginManifest, RegisteredPlugin } from "./types"; -import { getPluginComponent, onPluginRegistered } from "./registry"; +import { + getPluginComponent, + onPluginRegistered, + notifyPluginRegistry, + setPluginLoadError, +} from "./registry"; export function usePlugins() { const [manifests, setManifests] = useState([]); @@ -33,6 +38,8 @@ export function usePlugins() { useEffect(() => { if (manifests.length === 0) return; + const injectedScripts: HTMLScriptElement[] = []; + for (const manifest of manifests) { // Inject CSS if specified. if (manifest.css) { @@ -45,23 +52,49 @@ export function usePlugins() { } } - // Load JS bundle. - const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`; - if (loadedScripts.current.has(jsUrl)) continue; - loadedScripts.current.add(jsUrl); + // Load JS bundle. In dev, cache-bust so Vite HMR can clear the + // in-memory registry while the browser would otherwise never + // re-execute a previously cached