feat: add sidebar

This commit is contained in:
Austin Pickett 2026-04-22 23:25:17 -04:00
parent 7db2703b33
commit e5d2815b41
41 changed files with 2469 additions and 1391 deletions

View file

@ -89,6 +89,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -318,31 +319,6 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -1509,6 +1485,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.19.0" "undici-types": "~7.19.0"
} }
@ -1519,6 +1496,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1529,6 +1507,7 @@
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/scope-manager": "8.58.1",
@ -1558,6 +1537,7 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1", "@typescript-eslint/types": "8.58.1",
@ -1875,6 +1855,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2210,6 +2191,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -2895,6 +2877,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@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", "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chalk": "^5.3.0", "chalk": "^5.3.0",
"type-fest": "^4.18.2" "type-fest": "^4.18.2"
@ -5146,6 +5130,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5245,6 +5230,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -6017,6 +6003,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@ -6143,6 +6130,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6252,6 +6240,7 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@ -6660,6 +6649,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

34
web/package-lock.json generated
View file

@ -8,7 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@nous-research/ui": "^0.3.0", "@nous-research/ui": "^0.4.0",
"@observablehq/plot": "^0.6.17", "@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0", "@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@ -70,6 +70,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1058,9 +1059,9 @@
} }
}, },
"node_modules/@nous-research/ui": { "node_modules/@nous-research/ui": {
"version": "0.3.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.4.0.tgz",
"integrity": "sha512-konGgtV9lkzqYkWuoUGnROqavq1svTnGbERLKItvEXmsRz4xRtbAMHI8rK6sjGpHDpwvOUN3olcOhRLTGuVfcA==", "integrity": "sha512-wA9YImWLFjx3yWsb3TsquwG9VKZunupdovkOjnRboFjNAb3Jcf57o67xWafEPEm3VX6k6RP/+Y9zHWX0PUtZ4w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nanostores/react": "^1.0.0", "@nanostores/react": "^1.0.0",
@ -1103,6 +1104,7 @@
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"d3": "^7.9.0", "d3": "^7.9.0",
"interval-tree-1d": "^1.0.0", "interval-tree-1d": "^1.0.0",
@ -1755,6 +1757,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/webxr": "*", "@types/webxr": "*",
@ -2489,6 +2492,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -2498,6 +2502,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -2508,6 +2513,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -2572,6 +2578,7 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.0",
@ -2867,6 +2874,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3019,6 +3027,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3526,6 +3535,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -3839,6 +3849,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -4217,7 +4228,8 @@
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
@ -4532,6 +4544,7 @@
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
@ -4953,6 +4966,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^20.0.0 || >=22.0.0" "node": "^20.0.0 || >=22.0.0"
} }
@ -5080,6 +5094,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5151,6 +5166,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5170,6 +5186,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -5532,7 +5549,8 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
@ -5597,6 +5615,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5682,6 +5701,7 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -5818,6 +5839,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -13,7 +13,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@nous-research/ui": "^0.3.0", "@nous-research/ui": "^0.4.0",
"@observablehq/plot": "^0.6.17", "@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0", "@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",

View file

@ -1,31 +1,47 @@
import { useMemo } from "react"; import {
import { Routes, Route, NavLink, Navigate } from "react-router-dom"; useCallback,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
import { import {
Activity, Activity,
BarChart3, BarChart3,
Clock, Clock,
Code,
Database,
Download,
Eye,
FileText, FileText,
Globe,
Heart,
KeyRound, KeyRound,
Loader2,
Menu,
MessageSquare, MessageSquare,
Package, Package,
Settings,
Puzzle, Puzzle,
Sparkles, RotateCw,
Terminal, Settings,
Globe,
Database,
Shield, Shield,
Wrench, Sparkles,
Zap,
Heart,
Star, Star,
Code, Terminal,
Eye, Wrench,
X,
Zap,
} from "lucide-react"; } 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 { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop"; 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 ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage"; import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage"; import SessionsPage from "@/pages/SessionsPage";
@ -36,15 +52,17 @@ import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { PluginSlot, usePlugins } from "@/plugins"; import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { RegisteredPlugin } from "@/plugins"; import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes"; import { useTheme } from "@/themes";
/** Built-in route default page component. Used both for standard routing function RootRedirect() {
* and for resolving plugin `tab.override` values. Keys must match the return <Navigate to="/sessions" replace />;
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */ }
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
"/": StatusPage, /** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
const BUILTIN_ROUTES: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage, "/sessions": SessionsPage,
"/analytics": AnalyticsPage, "/analytics": AnalyticsPage,
"/logs": LogsPage, "/logs": LogsPage,
@ -55,7 +73,6 @@ const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
}; };
const BUILTIN_NAV: NavItem[] = [ const BUILTIN_NAV: NavItem[] = [
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
{ {
path: "/sessions", path: "/sessions",
labelKey: "sessions", labelKey: "sessions",
@ -75,9 +92,7 @@ const BUILTIN_NAV: NavItem[] = [
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
]; ];
// Plugins can reference any of these by name in their manifest — keeps bundle const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
// size sane vs. importing the full lucide-react set.
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Activity, Activity,
BarChart3, BarChart3,
Clock, Clock,
@ -100,24 +115,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Eye, Eye,
}; };
function resolveIcon( function resolveIcon(name: string): ComponentType<{ className?: string }> {
name: string,
): React.ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle; return ICON_MAP[name] ?? Puzzle;
} }
function buildNavItems( function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
builtIn: NavItem[],
plugins: RegisteredPlugin[],
): NavItem[] {
const items = [...builtIn]; const items = [...builtIn];
for (const { manifest } of plugins) { for (const manifest of manifests) {
// 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 `/`.
if (manifest.tab.override) continue; if (manifest.tab.override) continue;
// Hidden plugins register their component + slots but skip the nav.
if (manifest.tab.hidden) continue; if (manifest.tab.hidden) continue;
const pluginItem: NavItem = { const pluginItem: NavItem = {
@ -145,54 +151,58 @@ function buildNavItems(
return items; return items;
} }
/** Build the final route table, letting plugins override built-in pages. function buildRoutes(manifests: PluginManifest[]): Array<{
* key: string;
* Returns (path, Component, key) tuples. Plugins with `tab.override` path: string;
* win over both built-ins and other plugins (last registration wins if element: ReactNode;
* two plugins claim the same override, but we warn in dev). Plugins with }> {
* a regular `tab.path` register alongside built-ins as standalone const byOverride = new Map<string, PluginManifest>();
* routes. */ const addons: PluginManifest[] = [];
function buildRoutes(
plugins: RegisteredPlugin[],
): Array<{ key: string; path: string; Component: React.ComponentType }> {
const overrides = new Map<string, RegisteredPlugin>();
const addons: RegisteredPlugin[] = [];
for (const p of plugins) { for (const m of manifests) {
if (p.manifest.tab.override) { if (m.tab.override) {
overrides.set(p.manifest.tab.override, p); byOverride.set(m.tab.override, m);
} else { } else {
addons.push(p); addons.push(m);
} }
} }
const routes: Array<{ const routes: Array<{
key: string; key: string;
path: string; path: string;
Component: React.ComponentType; element: ReactNode;
}> = []; }> = [];
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) { for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
const override = overrides.get(path); const om = byOverride.get(path);
if (override) { if (om) {
routes.push({ routes.push({
key: `override:${override.manifest.name}`, key: `override:${om.name}`,
path, path,
Component: override.component, element: <PluginPage name={om.name} />,
}); });
} else { } else {
routes.push({ key: `builtin:${path}`, path, Component }); routes.push({ key: `builtin:${path}`, path, element: <Component /> });
} }
} }
for (const addon of addons) { for (const m of addons) {
// Don't double-register a plugin that shadows a built-in path via if (m.tab.hidden) continue;
// `tab.path` — `override` is the supported mechanism for that. if (BUILTIN_ROUTES[m.tab.path]) continue;
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
routes.push({ routes.push({
key: `plugin:${addon.manifest.name}`, key: `plugin:${m.name}`,
path: addon.manifest.tab.path, path: m.tab.path,
Component: addon.component, element: <PluginPage name={m.name} />,
});
}
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: <PluginPage name={m.name} />,
}); });
} }
@ -201,154 +211,125 @@ function buildRoutes(
export default function App() { export default function App() {
const { t } = useI18n(); const { t } = useI18n();
const { plugins } = usePlugins(); const { manifests } = usePlugins();
const { theme } = useTheme(); const { theme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navItems = useMemo( const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, plugins), () => buildNavItems(BUILTIN_NAV, manifests),
[plugins], [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 layoutVariant = theme.layoutVariant ?? "standard";
const showSidebar = layoutVariant === "cockpit"; const mainMaxWidth =
// Tiled layout drops the 1600px clamp so pages can use the full viewport; layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
// 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 ( return (
<div <div
data-layout-variant={layoutVariant} data-layout-variant={layoutVariant}
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden" className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
> >
<SelectionSwitcher /> <SelectionSwitcher />
<Backdrop /> <Backdrop />
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
CSS vars read by <Backdrop />. Plugins can also inject full
components into the backdrop layer via the `backdrop` slot
useful for scanlines, parallax stars, hero artwork, etc. */}
<PluginSlot name="backdrop" /> <PluginSlot name="backdrop" />
<header <header
className={cn( className={cn(
"fixed top-0 left-0 right-0 z-40", "lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
"flex items-center gap-2 px-3",
"border-b border-current/20", "border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm", "bg-background-base/90 backdrop-blur-sm",
)} )}
style={{ style={{
// Themes can tweak header chrome (background, border-image,
// clip-path) via these CSS vars. Unset vars compute to the
// property's initial value, so themes opt in per-property.
background: "var(--component-header-background)", background: "var(--component-header-background)",
borderImage: "var(--component-header-border-image)", borderImage: "var(--component-header-border-image)",
clipPath: "var(--component-header-clip-path)", clipPath: "var(--component-header-clip-path)",
}} }}
> >
<div className={cn("mx-auto flex h-12", mainMaxWidth)}> <button
<PluginSlot name="header-left" /> type="button"
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none"> onClick={() => setMobileOpen(true)}
<Grid aria-label={t.app.openNavigation}
className="h-full !border-t-0 !border-b-0" aria-expanded={mobileOpen}
style={{ aria-controls="app-sidebar"
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`, className={cn(
}} "inline-flex h-8 w-8 items-center justify-center",
> "text-midground/70 hover:text-midground transition-colors cursor-pointer",
<Cell className="flex items-center !p-0 !px-3 sm:!px-5"> "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
<Typography )}
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground" >
style={{ mixBlendMode: "plus-lighter" }} <Menu className="h-4 w-4" />
> </button>
Hermes
<br />
Agent
</Typography>
</Cell>
{navItems.map(({ path, label, labelKey, icon: Icon }) => ( <Typography
<Cell key={path} className="relative !p-0"> className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
<NavLink style={{ mixBlendMode: "plus-lighter" }}
to={path} >
end={path === "/"} {t.app.brand}
className={({ isActive }) => </Typography>
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 }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">
{labelKey
? ((t.app.nav as Record<string, string>)[
labelKey
] ?? label)
: label}
</span>
<span
aria-hidden
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</Cell>
))}
</Grid>
</div>
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
<PluginSlot name="header-right" />
<ThemeSwitcher />
<LanguageSwitcher />
<Typography
mondwest
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
>
{t.app.webUi}
</Typography>
</Cell>
</Grid>
</div>
</header> </header>
{/* Full-width banner slot under the nav, outside the main clamp {mobileOpen && (
useful for marquee/alert/status strips themes want to show <button
above page content. */} type="button"
aria-label={t.app.closeNavigation}
onClick={closeMobile}
className={cn(
"lg:hidden fixed inset-0 z-40",
"bg-black/60 backdrop-blur-sm cursor-pointer",
)}
/>
)}
<PluginSlot name="header-banner" /> <PluginSlot name="header-banner" />
<div <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
className={cn( <div className="flex min-h-0 min-w-0 flex-1">
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
mainMaxWidth,
showSidebar && "flex gap-4 sm:gap-6",
)}
>
{showSidebar && (
<aside <aside
id="app-sidebar"
aria-label={t.app.navigation}
className={cn( className={cn(
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4", "fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
"hidden lg:block", "border-r border-current/20",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
)} )}
style={{ style={{
background: "var(--component-sidebar-background)", background: "var(--component-sidebar-background)",
@ -356,75 +337,305 @@ export default function App() {
borderImage: "var(--component-sidebar-border-image)", borderImage: "var(--component-sidebar-border-image)",
}} }}
> >
<PluginSlot <div
name="sidebar" className={cn(
fallback={ "flex h-14 shrink-0 items-center justify-between gap-2 px-5",
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide"> "border-b border-current/20",
{/* Cockpit layout with no sidebar plugin rare but valid; )}
the space still exists so the grid doesn't shift when >
a plugin loads asynchronously. */} <Typography
sidebar slot empty className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
</div> style={{ mixBlendMode: "plus-lighter" }}
} >
/> Hermes
</aside> <br />
)} Agent
</Typography>
<main className="min-w-0 flex-1"> <button
<PluginSlot name="pre-main" /> type="button"
<Routes> onClick={closeMobile}
{routes.map(({ key, path, Component }) => ( aria-label={t.app.closeNavigation}
<Route key={key} path={path} element={<Component />} /> className={cn(
))} "lg:hidden inline-flex h-7 w-7 items-center justify-center",
<Route path="*" element={<Navigate to="/" replace />} /> "text-midground/70 hover:text-midground transition-colors cursor-pointer",
</Routes> "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
<PluginSlot name="post-main" /> )}
</main> >
<X className="h-4 w-4" />
</button>
</div>
<div className="shrink-0 border-b border-current/10">
<PluginSlot name="header-left" />
</div>
<nav
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-2"
aria-label={t.app.navigation}
>
<ul className="flex flex-col">
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
<li key={path}>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest 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 }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{labelKey
? ((t.app.nav as Record<string, string>)[
labelKey
] ?? label)
: label}
</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-3 py-2",
"border-t border-current/20",
)}
>
<div className="flex min-w-0 items-center gap-2">
<PluginSlot name="header-right" />
<ThemeSwitcher dropUp />
<LanguageSwitcher />
</div>
</div>
<div
className={cn(
"shrink-0 flex items-center justify-between gap-2",
"border-t border-current/20",
"px-5 py-3",
)}
>
<PluginSlot
name="footer-left"
fallback={
<Typography
mondwest
className="text-[0.7rem] tracking-[0.12em] opacity-60"
>
{t.app.brand}
</Typography>
}
/>
<PluginSlot
name="footer-right"
fallback={
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
}
/>
</div>
<SidebarFooter />
</aside>
<div className="hidden shrink-0 lg:block lg:w-64" aria-hidden />
<PageHeaderProvider pluginTabs={pluginTabMeta}>
<main
className={cn(
"relative z-2 min-w-0 min-h-0 flex-1",
"overflow-y-auto",
"px-3 pb-4 sm:px-6 sm:pb-8",
"pt-2 sm:pt-4 lg:pt-6",
)}
>
<PluginSlot name="pre-main" />
<div className={cn("mx-auto w-full", mainMaxWidth)}>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={<Navigate to="/sessions" replace />}
/>
</Routes>
</div>
<PluginSlot name="post-main" />
</main>
</PageHeaderProvider>
</div>
</div> </div>
<footer className="relative z-2 border-t border-current/20">
<Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
<PluginSlot
name="footer-left"
fallback={
<Typography
mondwest
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
>
{t.app.footer.name}
</Typography>
}
/>
</Cell>
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
<PluginSlot
name="footer-right"
fallback={
<Typography
mondwest
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
}
/>
</Cell>
</Grid>
</footer>
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
above everything else. Each plugin is responsible for its own
pointer-events and z-index. */}
<PluginSlot name="overlay" /> <PluginSlot name="overlay" />
</div> </div>
); );
} }
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 (
<div
className={cn(
"shrink-0 flex flex-col",
"border-t border-current/10",
"py-1",
)}
>
<span
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
>
{t.app.system}
</span>
<SidebarStatusStrip />
<ul className="flex flex-col">
{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 (
<li key={action}>
<button
type="button"
onClick={() => handleClick(action)}
disabled={disabled}
aria-busy={busy}
className={cn(
"group relative flex w-full items-center gap-3",
"px-5 py-1.5",
"font-mondwest text-[0.75rem] tracking-[0.1em]",
"text-left whitespace-nowrap transition-opacity cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
busy
? "text-midground opacity-100"
: "opacity-60 hover:opacity-100",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && spin && "animate-spin",
isActionRunning && !spin && "animate-pulse",
)}
/>
)}
<span className="truncate">{displayLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
</li>
);
})}
</ul>
</div>
);
}
interface NavItem { interface NavItem {
icon: React.ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
label: string; label: string;
labelKey?: string; labelKey?: string;
path: string; path: string;
} }
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;
label: string;
runningLabel: string;
spin: boolean;
}

View file

@ -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 (
<ConfirmDialog
open={open}
onCancel={onCancel}
onConfirm={onConfirm}
title={title}
description={description}
loading={loading}
destructive
confirmLabel={confirmLabel ?? t.common.delete}
cancelLabel={cancelLabel ?? t.common.cancel}
/>
);
}
interface DeleteConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}

View file

@ -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 (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{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 (
<div
key={name}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex items-center gap-3 min-w-0 w-full">
<IconComponent
className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge
variant={display.variant}
className="shrink-0 self-start sm:self-center"
>
{display.variant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}

View file

@ -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 (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2.5",
"border-t border-current/10",
)}
>
<Typography
mondwest
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
<a
href="https://nousresearch.com"
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground",
"transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</a>
</div>
);
}

View file

@ -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 (
<div className="px-5 py-1.5" aria-hidden>
<div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
</div>
);
}
const gw = gatewayLine(status, t);
const { activeSessionsLabel, gatewayStatusLabel } = t.app;
return (
<Link
to="/sessions"
title={t.app.statusOverview}
className={cn(
"block text-left",
"px-5 pb-2 pt-0.5",
"text-muted-foreground/70",
"transition-colors hover:text-muted-foreground/90",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
"focus-visible:ring-inset",
)}
>
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]">
<p className="break-words">
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
</p>
<p className="break-words">
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-muted-foreground/70">
{status.active_sessions}
</span>
</p>
</div>
</Link>
);
}
function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
const g = t.app.gatewayStrip;
const byState: Record<string, { label: string; tone: string }> = {
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" };
}

View file

@ -11,8 +11,12 @@ import { cn } from "@/lib/utils";
* glow) so users can preview the palette before committing. User-defined * glow) so users can preview the palette before committing. User-defined
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in * themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
* `BUILTIN_THEMES` render without swatches and apply the default palette. * `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 { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -73,7 +77,8 @@ export function ThemeSwitcher() {
role="listbox" role="listbox"
aria-label={t.theme?.title ?? "Theme"} aria-label={t.theme?.title ?? "Theme"}
className={cn( 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", "border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
)} )}
@ -166,3 +171,7 @@ function PlaceholderSwatch() {
/> />
); );
} }
interface ThemeSwitcherProps {
dropUp?: boolean;
}

View file

@ -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<HTMLDivElement>(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<HTMLButtonElement>("[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(
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby={description ? "confirm-dialog-desc" : undefined}
onClick={(e) => {
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]",
)}
>
<div
ref={dialogRef}
className={cn(
"relative w-full max-w-md mx-4",
"border border-border bg-card shadow-lg",
"animate-[dialog-in_180ms_ease-out]",
)}
>
<div className="flex items-start gap-3 p-4 border-b border-border">
{destructive && (
<div
aria-hidden
className="mt-0.5 shrink-0 text-destructive"
>
<AlertTriangle className="h-4 w-4" />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<h2
id="confirm-dialog-title"
className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter"
>
{title}
</h2>
{description && (
<p
id="confirm-dialog-desc"
className="font-mondwest text-xs text-muted-foreground leading-relaxed"
>
{description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 p-3">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
disabled={loading}
>
{cancelLabel}
</Button>
<Button
data-confirm
type="button"
variant={destructive ? "destructive" : "default"}
size="sm"
onClick={onConfirm}
disabled={loading}
>
{loading ? "…" : confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
);
}
interface ConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
destructive?: boolean;
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}

View file

@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
export function Segmented<T extends string>({
className,
onChange,
options,
size = "sm",
value,
}: SegmentedProps<T>) {
return (
<div
role="radiogroup"
className={cn(
"inline-flex border border-border bg-background/30",
className,
)}
>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(opt.value)}
className={cn(
"font-mondwest tracking-[0.1em] uppercase",
"transition-colors cursor-pointer whitespace-nowrap",
"border-r border-border last:border-r-0",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
size === "sm" && "h-7 px-2.5 text-[0.65rem]",
size === "md" && "h-8 px-3 text-xs",
active
? "bg-foreground/90 text-background"
: "text-muted-foreground hover:bg-foreground/10 hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
</div>
);
}
export function FilterGroup({
children,
className,
label,
}: FilterGroupProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{label}
</span>
{children}
</div>
);
}
interface FilterGroupProps {
children: React.ReactNode;
className?: string;
label: string;
}
interface SegmentedOption<T extends string> {
label: string;
value: T;
}
interface SegmentedProps<T extends string> {
className?: string;
onChange: (value: T) => void;
options: SegmentedOption<T>[];
size?: "sm" | "md";
value: T;
}

View file

@ -5,15 +5,18 @@ export function Switch({
onCheckedChange, onCheckedChange,
className, className,
disabled, disabled,
id,
}: { }: {
checked: boolean; checked: boolean;
onCheckedChange: (v: boolean) => void; onCheckedChange: (v: boolean) => void;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
id?: string;
}) { }) {
return ( return (
<button <button
type="button" type="button"
id={id}
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}

View file

@ -0,0 +1,89 @@
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { PageHeaderContext } from "./page-header-context";
import { resolvePageTitle } from "@/lib/resolve-page-title";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function PageHeaderProvider({
children,
pluginTabs,
}: {
children: ReactNode;
pluginTabs: { path: string; label: string }[];
}) {
const { pathname } = useLocation();
const { t } = useI18n();
const [titleOverride, setTitleOverride] = useState<string | null>(null);
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
const [end, setEnd] = useState<ReactNode>(null);
// Clear any per-page title / toolbar slots when the path changes. Child routes
// re-fill these on mount via usePageHeader.
/* eslint-disable react-hooks/set-state-in-effect */
useLayoutEffect(() => {
setTitleOverride(null);
setAfterTitle(null);
setEnd(null);
}, [pathname]);
/* eslint-enable react-hooks/set-state-in-effect */
const defaultTitle = useMemo(
() => resolvePageTitle(pathname, t, pluginTabs),
[pathname, t, pluginTabs],
);
const displayTitle = titleOverride ?? defaultTitle;
const value = useMemo(
() => ({
setAfterTitle,
setEnd,
setTitle: setTitleOverride,
}),
[],
);
return (
<PageHeaderContext.Provider value={value}>
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
<header
className={cn(
"z-1 box-border h-14 shrink-0 border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm",
"overflow-hidden",
)}
role="banner"
>
<div
className={cn(
"flex h-full w-full min-w-0 flex-1 flex-col justify-center gap-2",
"px-3 py-2 sm:px-6",
"min-h-14 sm:min-h-0 sm:flex-row sm:items-center sm:gap-3 sm:py-0",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<h1
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{displayTitle}
</h1>
{afterTitle}
</div>
{end ? (
<div className="flex w-full min-w-0 justify-end sm:max-w-md sm:flex-1">
{end}
</div>
) : null}
</div>
</header>
<main
className="min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
>
{children}
</main>
</div>
</PageHeaderContext.Provider>
);
}

View file

@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ActionStatusResponse } from "@/lib/api";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import {
SystemActionsContext,
type SystemAction,
} from "./system-actions-context";
const ACTION_NAMES: Record<SystemAction, string> = {
restart: "gateway-restart",
update: "hermes-update",
};
export function SystemActionsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const { t } = useI18n();
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]);
const runAction = useCallback(
async (action: SystemAction) => {
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);
}
},
[t.status.actionFailed],
);
const dismissLog = useCallback(() => {
setActiveAction(null);
setActionStatus(null);
}, []);
const isRunning = activeAction !== null && actionStatus?.running !== false;
const isBusy = pendingAction !== null || isRunning;
return (
<SystemActionsContext.Provider
value={{
actionStatus,
activeAction,
dismissLog,
isBusy,
isRunning,
pendingAction,
runAction,
}}
>
{children}
<Toast toast={toast} />
</SystemActionsContext.Provider>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}

View file

@ -0,0 +1,12 @@
import { createContext } from "react";
import type { ReactNode } from "react";
export interface PageHeaderContextValue {
setAfterTitle: (node: ReactNode) => void;
setEnd: (node: ReactNode) => void;
setTitle: (title: string | null) => void;
}
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
null,
);

View file

@ -0,0 +1,18 @@
import { createContext } from "react";
import type { ActionStatusResponse } from "@/lib/api";
export const SystemActionsContext = createContext<SystemActionsState | null>(
null,
);
export type SystemAction = "restart" | "update";
export interface SystemActionsState {
actionStatus: ActionStatusResponse | null;
activeAction: SystemAction | null;
dismissLog: () => void;
isBusy: boolean;
isRunning: boolean;
pendingAction: SystemAction | null;
runAction: (action: SystemAction) => Promise<void>;
}

View file

@ -0,0 +1,10 @@
import { useContext } from "react";
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
if (!ctx) {
throw new Error("usePageHeader must be used within a PageHeaderProvider");
}
return ctx;
}

View file

@ -0,0 +1,15 @@
import { useContext } from "react";
import {
SystemActionsContext,
type SystemActionsState,
} from "./system-actions-context";
export function useSystemActions(): SystemActionsState {
const ctx = useContext(SystemActionsContext);
if (!ctx) {
throw new Error(
"useSystemActions must be used within a SystemActionsProvider",
);
}
return ctx;
}

View file

@ -0,0 +1,41 @@
import { useCallback, useState } from "react";
export function useConfirmDelete<TId>({
onDelete,
}: {
onDelete: (id: TId) => Promise<void>;
}) {
const [pendingId, setPendingId] = useState<TId | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const requestDelete = useCallback((id: TId) => {
setPendingId(id);
}, []);
const cancel = useCallback(() => {
if (!isDeleting) setPendingId(null);
}, [isDeleting]);
const confirm = useCallback(async () => {
if (pendingId === null) return;
const id = pendingId;
setIsDeleting(true);
try {
await onDelete(id);
setPendingId(null);
} catch {
// Dialog stays open; caller can surface errors in onDelete before rethrowing
} finally {
setIsDeleting(false);
}
}, [pendingId, onDelete]);
return {
cancel,
confirm,
isDeleting,
isOpen: pendingId !== null,
pendingId,
requestDelete,
} as const;
}

View file

@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
const POLL_MS = 10_000;
/**
* Light-weight status poll for the app shell (sidebar). The Status page uses
* its own faster interval; we keep this slower to avoid duplicate load.
*/
export function useSidebarStatus() {
const [status, setStatus] = useState<StatusResponse | null>(null);
useEffect(() => {
const load = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
};
load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, []);
return status;
}

View file

@ -6,6 +6,7 @@ export const en: Translations = {
saving: "Saving...", saving: "Saving...",
cancel: "Cancel", cancel: "Cancel",
close: "Close", close: "Close",
confirm: "Confirm",
delete: "Delete", delete: "Delete",
refresh: "Refresh", refresh: "Refresh",
retry: "Retry", retry: "Retry",
@ -42,26 +43,43 @@ export const en: Translations = {
expand: "Expand", expand: "Expand",
general: "General", general: "General",
messaging: "Messaging", messaging: "Messaging",
pluginLoadFailed:
"Could not load this plugins script. Check the Network tab (dashboard-plugins/…) and the servers plugin path.",
pluginNotRegistered:
"The plugins script did not call register(), or the script errored. Open the browser console for details.",
}, },
app: { app: {
brand: "Hermes Agent", brand: "Hermes Agent",
brandShort: "HA", brandShort: "HA",
webUi: "Web UI", closeNavigation: "Close navigation",
footer: { footer: {
name: "Hermes Agent",
org: "Nous Research", org: "Nous Research",
}, },
nav: { activeSessionsLabel: "Active Sessions:",
status: "Status", gatewayStatusLabel: "Gateway Status:",
sessions: "Sessions", gatewayStrip: {
analytics: "Analytics", failed: "Start failed",
logs: "Logs", off: "Off",
cron: "Cron", running: "Running",
skills: "Skills", starting: "Starting",
config: "Config", stopped: "Stopped",
keys: "Keys",
}, },
nav: {
analytics: "Analytics",
config: "Config",
cron: "Cron",
keys: "Keys",
logs: "Logs",
sessions: "Sessions",
skills: "Skills",
},
navigation: "Navigation",
openNavigation: "Open navigation",
sessionsActiveCount: "{count} active",
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
}, },
status: { status: {
@ -106,6 +124,11 @@ export const en: Translations = {
noMessages: "No messages", noMessages: "No messages",
untitledSession: "Untitled session", untitledSession: "Untitled session",
deleteSession: "Delete session", deleteSession: "Delete session",
confirmDeleteTitle: "Delete session?",
confirmDeleteMessage:
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
previousPage: "Previous page", previousPage: "Previous page",
nextPage: "Next page", nextPage: "Next page",
roles: { roles: {
@ -153,6 +176,9 @@ export const en: Translations = {
}, },
cron: { cron: {
confirmDeleteMessage:
"This removes the job from the schedule. This cannot be undone.",
confirmDeleteTitle: "Delete scheduled job?",
newJob: "New Cron Job", newJob: "New Cron Job",
nameOptional: "Name (optional)", nameOptional: "Name (optional)",
namePlaceholder: "e.g. Daily summary", namePlaceholder: "e.g. Daily summary",
@ -182,6 +208,8 @@ export const en: Translations = {
searchPlaceholder: "Search skills and toolsets...", searchPlaceholder: "Search skills and toolsets...",
enabledOf: "{enabled}/{total} enabled", enabledOf: "{enabled}/{total} enabled",
all: "All", all: "All",
categories: "Categories",
filters: "Filters",
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/", noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
noSkillsMatch: "No skills match your search or filter.", noSkillsMatch: "No skills match your search or filter.",
skillCount: "{count} skill{s}", skillCount: "{count} skill{s}",
@ -197,6 +225,8 @@ export const en: Translations = {
config: { config: {
configPath: "~/.hermes/config.yaml", configPath: "~/.hermes/config.yaml",
filters: "Filters",
sections: "Sections",
exportConfig: "Export config as JSON", exportConfig: "Export config as JSON",
importConfig: "Import config from JSON", importConfig: "Import config from JSON",
resetDefaults: "Reset to defaults", resetDefaults: "Reset to defaults",
@ -231,8 +261,11 @@ export const en: Translations = {
}, },
env: { env: {
description: "Manage API keys and secrets stored in",
changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.", changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.",
confirmClearMessage:
"The stored value for this variable will be removed from your .env file. This cannot be undone from the UI.",
confirmClearTitle: "Clear this key?",
description: "Manage API keys and secrets stored in",
hideAdvanced: "Hide Advanced", hideAdvanced: "Hide Advanced",
showAdvanced: "Show Advanced", showAdvanced: "Show Advanced",
llmProviders: "LLM Providers", llmProviders: "LLM Providers",

View file

@ -7,6 +7,7 @@ export interface Translations {
saving: string; saving: string;
cancel: string; cancel: string;
close: string; close: string;
confirm: string;
delete: string; delete: string;
refresh: string; refresh: string;
retry: string; retry: string;
@ -43,27 +44,42 @@ export interface Translations {
expand: string; expand: string;
general: string; general: string;
messaging: string; messaging: string;
pluginLoadFailed: string;
pluginNotRegistered: string;
}; };
// ── App shell ── // ── App shell ──
app: { app: {
brand: string; brand: string;
brandShort: string; brandShort: string;
webUi: string; closeNavigation: string;
footer: { footer: {
name: string;
org: string; org: string;
}; };
nav: { activeSessionsLabel: string;
status: string; gatewayStatusLabel: string;
sessions: string; gatewayStrip: {
analytics: string; failed: string;
logs: string; off: string;
cron: string; running: string;
skills: string; starting: string;
config: string; stopped: string;
keys: string;
}; };
nav: {
analytics: string;
config: string;
cron: string;
keys: string;
logs: string;
sessions: string;
skills: string;
};
navigation: string;
openNavigation: string;
sessionsActiveCount: string;
statusOverview: string;
system: string;
webUi: string;
}; };
// ── Status page ── // ── Status page ──
@ -110,6 +126,10 @@ export interface Translations {
noMessages: string; noMessages: string;
untitledSession: string; untitledSession: string;
deleteSession: string; deleteSession: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
previousPage: string; previousPage: string;
nextPage: string; nextPage: string;
roles: { roles: {
@ -160,6 +180,8 @@ export interface Translations {
// ── Cron page ── // ── Cron page ──
cron: { cron: {
confirmDeleteMessage: string;
confirmDeleteTitle: string;
newJob: string; newJob: string;
nameOptional: string; nameOptional: string;
namePlaceholder: string; namePlaceholder: string;
@ -190,6 +212,8 @@ export interface Translations {
searchPlaceholder: string; searchPlaceholder: string;
enabledOf: string; enabledOf: string;
all: string; all: string;
categories: string;
filters: string;
noSkills: string; noSkills: string;
noSkillsMatch: string; noSkillsMatch: string;
skillCount: string; skillCount: string;
@ -206,6 +230,8 @@ export interface Translations {
// ── Config page ── // ── Config page ──
config: { config: {
configPath: string; configPath: string;
filters: string;
sections: string;
exportConfig: string; exportConfig: string;
importConfig: string; importConfig: string;
resetDefaults: string; resetDefaults: string;
@ -241,20 +267,22 @@ export interface Translations {
// ── Env / Keys page ── // ── Env / Keys page ──
env: { env: {
description: string;
changesNote: string; changesNote: string;
hideAdvanced: string; confirmClearMessage: string;
showAdvanced: string; confirmClearTitle: string;
llmProviders: string; description: string;
providersConfigured: string; enterValue: string;
getKey: string; getKey: string;
hideAdvanced: string;
hideValue: string;
keysCount: string;
llmProviders: string;
notConfigured: string; notConfigured: string;
notSet: string; notSet: string;
keysCount: string; providersConfigured: string;
enterValue: string;
replaceCurrentValue: string; replaceCurrentValue: string;
showAdvanced: string;
showValue: string; showValue: string;
hideValue: string;
}; };
// ── OAuth ── // ── OAuth ──

View file

@ -6,6 +6,7 @@ export const zh: Translations = {
saving: "保存中...", saving: "保存中...",
cancel: "取消", cancel: "取消",
close: "关闭", close: "关闭",
confirm: "确认",
delete: "删除", delete: "删除",
refresh: "刷新", refresh: "刷新",
retry: "重试", retry: "重试",
@ -42,26 +43,42 @@ export const zh: Translations = {
expand: "展开", expand: "展开",
general: "通用", general: "通用",
messaging: "消息平台", messaging: "消息平台",
pluginLoadFailed:
"无法加载此插件的脚本。请检查网络请求dashboard-plugins/…)以及服务器上的插件路径。",
pluginNotRegistered: "插件脚本未调用 register(),或执行出错。请打开浏览器控制台查看详情。",
}, },
app: { app: {
brand: "Hermes Agent", brand: "Hermes Agent",
brandShort: "HA", brandShort: "HA",
webUi: "管理面板", closeNavigation: "关闭导航",
footer: { footer: {
name: "Hermes Agent",
org: "Nous Research", org: "Nous Research",
}, },
nav: { activeSessionsLabel: "活跃会话:",
status: "状态", gatewayStatusLabel: "网关状态:",
sessions: "会话", gatewayStrip: {
analytics: "分析", failed: "启动失败",
logs: "日志", off: "关闭",
cron: "定时任务", running: "运行中",
skills: "技能", starting: "启动中",
config: "配置", stopped: "已停止",
keys: "密钥",
}, },
nav: {
analytics: "分析",
config: "配置",
cron: "定时任务",
keys: "密钥",
logs: "日志",
sessions: "会话",
skills: "技能",
},
navigation: "导航",
openNavigation: "打开导航",
sessionsActiveCount: "{count} 个活跃",
statusOverview: "状态概览",
system: "系统",
webUi: "管理面板",
}, },
status: { status: {
@ -106,6 +123,10 @@ export const zh: Translations = {
noMessages: "暂无消息", noMessages: "暂无消息",
untitledSession: "无标题会话", untitledSession: "无标题会话",
deleteSession: "删除会话", deleteSession: "删除会话",
confirmDeleteTitle: "删除会话?",
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
previousPage: "上一页", previousPage: "上一页",
nextPage: "下一页", nextPage: "下一页",
roles: { roles: {
@ -153,6 +174,8 @@ export const zh: Translations = {
}, },
cron: { cron: {
confirmDeleteMessage: "将从此计划移除该任务,此操作无法撤销。",
confirmDeleteTitle: "删除定时任务?",
newJob: "新建定时任务", newJob: "新建定时任务",
nameOptional: "名称(可选)", nameOptional: "名称(可选)",
namePlaceholder: "例如:每日总结", namePlaceholder: "例如:每日总结",
@ -182,6 +205,8 @@ export const zh: Translations = {
searchPlaceholder: "搜索技能和工具集...", searchPlaceholder: "搜索技能和工具集...",
enabledOf: "已启用 {enabled}/{total}", enabledOf: "已启用 {enabled}/{total}",
all: "全部", all: "全部",
categories: "分类",
filters: "筛选",
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载", noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
noSkillsMatch: "没有匹配的技能。", noSkillsMatch: "没有匹配的技能。",
skillCount: "{count} 个技能", skillCount: "{count} 个技能",
@ -197,6 +222,8 @@ export const zh: Translations = {
config: { config: {
configPath: "~/.hermes/config.yaml", configPath: "~/.hermes/config.yaml",
filters: "筛选",
sections: "分类",
exportConfig: "导出配置为 JSON", exportConfig: "导出配置为 JSON",
importConfig: "从 JSON 导入配置", importConfig: "从 JSON 导入配置",
resetDefaults: "恢复默认值", resetDefaults: "恢复默认值",
@ -231,8 +258,10 @@ export const zh: Translations = {
}, },
env: { env: {
description: "管理存储在以下位置的 API 密钥和凭据",
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。", changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
confirmClearMessage: "该变量的已存值将从 .env 文件中删除。无法在此界面撤销。",
confirmClearTitle: "清除此密钥?",
description: "管理存储在以下位置的 API 密钥和凭据",
hideAdvanced: "隐藏高级选项", hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项", showAdvanced: "显示高级选项",
llmProviders: "LLM 提供商", llmProviders: "LLM 提供商",

View file

@ -73,6 +73,10 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1)); --spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
} }
#root {
min-height: 100dvh;
}
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable /* Nousnet's hermes-agent layout bumps `small` and `code` to readable
dashboard sizes. Keep in sync. */ dashboard sizes. Keep in sync. */
small { font-size: 1.0625rem; } small { font-size: 1.0625rem; }
@ -125,6 +129,16 @@ code { font-size: 0.875rem; }
to { opacity: 0; transform: translateX(16px); } to { opacity: 0; transform: translateX(16px); }
} }
/* Generic fade + dialog entrance used by popovers and confirm dialogs. */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Hide scrollbar utility — used by the header's overflow-x nav row. */ /* Hide scrollbar utility — used by the header's overflow-x nav row. */
.scrollbar-none { .scrollbar-none {
-ms-overflow-style: none; -ms-overflow-style: none;

View file

@ -513,7 +513,12 @@ export interface PluginManifestResponse {
description: string; description: string;
icon: string; icon: string;
version: string; version: string;
tab: { path: string; position: string }; tab: {
path: string;
position?: string;
override?: string;
hidden?: boolean;
};
entry: string; entry: string;
css?: string | null; css?: string | null;
has_api: boolean; has_api: boolean;

View file

@ -0,0 +1,31 @@
import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/sessions": "sessions",
"/analytics": "analytics",
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/config": "config",
"/env": "keys",
};
export function resolvePageTitle(
pathname: string,
t: Translations,
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/") {
return t.app.nav.sessions;
}
const plugin = pluginTabs.find((p) => p.path === normalized);
if (plugin) {
return plugin.label;
}
const key = BUILTIN[normalized];
if (key) {
return t.app.nav[key];
}
return t.app.webUi;
}

View file

@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import { SystemActionsProvider } from "./contexts/SystemActions";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { exposePluginSDK } from "./plugins"; import { exposePluginSDK } from "./plugins";
import { ThemeProvider } from "./themes"; import { ThemeProvider } from "./themes";
@ -14,7 +15,9 @@ createRoot(document.getElementById("root")!).render(
<BrowserRouter> <BrowserRouter>
<I18nProvider> <I18nProvider>
<ThemeProvider> <ThemeProvider>
<App /> <SystemActionsProvider>
<App />
</SystemActionsProvider>
</ThemeProvider> </ThemeProvider>
</I18nProvider> </I18nProvider>
</BrowserRouter>, </BrowserRouter>,

View file

@ -1,16 +1,19 @@
import { useEffect, useState, useCallback } from "react"; import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { import {
BarChart3, BarChart3,
Brain, Brain,
Cpu, Cpu,
Hash, Hash,
RefreshCw,
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
import { timeAgo } from "@/lib/utils"; import { timeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
const PERIODS = [ const PERIODS = [
@ -281,6 +284,7 @@ export default function AnalyticsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { t } = useI18n(); const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@ -292,28 +296,60 @@ export default function AnalyticsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [days]); }, [days]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<Badge variant="secondary" className="text-[10px]">
{periodLabel}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
variant={days === p.days ? "default" : "outline"}
size="sm"
className="h-7 min-w-0 text-xs"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={load}
disabled={loading}
className="h-7 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
{PERIODS.map((p) => (
<Button
key={p.label}
variant={days === p.days ? "default" : "outline"}
size="sm"
className="text-xs h-7"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
{loading && !data && ( {loading && !data && (
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo } from "react"; import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import { import {
Code, Code,
Download, Download,
@ -8,7 +8,6 @@ import {
Search, Search,
Upload, Upload,
X, X,
ChevronRight,
Settings2, Settings2,
FileText, FileText,
Settings, Settings,
@ -27,6 +26,7 @@ import {
MessageCircle, MessageCircle,
Wrench, Wrench,
FileQuestion, FileQuestion,
Filter,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested"; import { getNestedValue, setNestedValue } from "@/lib/nested";
@ -38,6 +38,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
@ -85,6 +86,35 @@ export default function ConfigPage() {
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n(); const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
if (!config || !schema) {
setEnd(null);
return;
}
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.search]);
function prettyCategoryName(cat: string): string { function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories; const key = cat as keyof typeof t.config.categories;
@ -366,62 +396,66 @@ export default function ConfigPage() {
</Card> </Card>
) : ( ) : (
/* ═══════════════ Form Mode ═══════════════ */ /* ═══════════════ Form Mode ═══════════════ */
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}> <div className="flex flex-col sm:flex-row gap-4">
{/* ---- Sidebar — horizontal scroll on mobile, fixed column on sm+ ---- */} {/* ---- Filter panel ---- */}
<div className="sm:w-52 sm:shrink-0"> <aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1"> <div className="sm:sticky sm:top-4">
{/* Search */} <div className="flex flex-col border border-border bg-muted/20">
<div className="relative mb-2 hidden sm:block"> {/* Panel heading */}
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Input <Filter className="h-3 w-3 text-muted-foreground" />
className="pl-8 h-8 text-xs" <span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
placeholder={t.common.search} {t.config.filters}
value={searchQuery} </span>
onChange={(e) => setSearchQuery(e.target.value)} </div>
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Category nav — horizontal scroll on mobile */} {/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0"> <div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{categories.map((cat) => { {t.config.sections}
const isActive = !isSearching && activeCategory === cat; </div>
return (
<button {/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
key={cat} <div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
type="button" {categories.map((cat) => {
onClick={() => { const isActive = !isSearching && activeCategory === cat;
setSearchQuery("");
setActiveCategory(cat); return (
}} <button
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${ key={cat}
isActive type="button"
? "bg-primary/10 text-primary font-medium" onClick={() => {
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" setSearchQuery("");
}`} setActiveCategory(cat);
> }}
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" /> className={`
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span> group flex items-center gap-2 px-2 py-1
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}> rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
{categoryCounts[cat] || 0} transition-colors
</span> ${
{isActive && ( isActive
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" /> ? "bg-foreground/10 text-foreground"
)} : "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
</button> }
); `}
})} >
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{categoryCounts[cat] || 0}
</span>
</button>
);
})}
</div>
</div> </div>
</div> </div>
</div> </aside>
{/* ---- Content ---- */} {/* ---- Content ---- */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View file

@ -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 { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { H2 } from "@nous-research/ui"; import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api"; import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast"; import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -40,17 +42,17 @@ export default function CronPage() {
const [deliver, setDeliver] = useState("local"); const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const loadJobs = () => { const loadJobs = useCallback(() => {
api api
.getCronJobs() .getCronJobs()
.then(setJobs) .then(setJobs)
.catch(() => showToast(t.common.loading, "error")) .catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; }, [showToast, t.common.loading]);
useEffect(() => { useEffect(() => {
loadJobs(); loadJobs();
}, []); }, [loadJobs]);
const handleCreate = async () => { const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) { if (!prompt.trim() || !schedule.trim()) {
@ -113,18 +115,25 @@ export default function CronPage() {
} }
}; };
const handleDelete = async (job: CronJob) => { const jobDelete = useConfirmDelete({
try { onDelete: useCallback(
await api.deleteCronJob(job.id); async (id: string) => {
showToast( const job = jobs.find((j) => j.id === id);
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, try {
"success", await api.deleteCronJob(id);
); showToast(
loadJobs(); `${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
} catch (e) { "success",
showToast(`${t.status.error}: ${e}`, "error"); );
} loadJobs();
}; } catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
if (loading) { if (loading) {
return ( return (
@ -134,10 +143,27 @@ export default function CronPage() {
); );
} }
const pendingJob = jobDelete.pendingId
? jobs.find((j) => j.id === jobDelete.pendingId)
: null;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Toast toast={toast} /> <Toast toast={toast} />
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
{/* Create new job form */} {/* Create new job form */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -311,7 +337,7 @@ export default function CronPage() {
size="icon" size="icon"
title={t.common.delete} title={t.common.delete}
aria-label={t.common.delete} aria-label={t.common.delete}
onClick={() => handleDelete(job)} onClick={() => jobDelete.requestDelete(job.id)}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View file

@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Eye, Eye,
EyeOff, EyeOff,
@ -16,8 +16,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { EnvVarInfo } 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 { Toast } from "@/components/Toast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { useToast } from "@/hooks/useToast";
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard"; import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -95,6 +97,7 @@ function EnvVarRow({
onClear, onClear,
onReveal, onReveal,
onCancelEdit, onCancelEdit,
clearDialogOpen = false,
compact = false, compact = false,
}: { }: {
varKey: string; varKey: string;
@ -107,6 +110,7 @@ function EnvVarRow({
onClear: (key: string) => void; onClear: (key: string) => void;
onReveal: (key: string) => void; onReveal: (key: string) => void;
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
compact?: boolean; compact?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@ -219,7 +223,7 @@ function EnvVarRow({
{info.is_set && ( {info.is_set && (
<Button size="sm" variant="ghost" <Button size="sm" variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}> onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : t.common.clear} {saving === varKey ? "..." : t.common.clear}
</Button> </Button>
@ -261,6 +265,7 @@ function ProviderGroupCard({
onClear, onClear,
onReveal, onReveal,
onCancelEdit, onCancelEdit,
clearDialogOpen = false,
}: { }: {
group: ProviderGroup; group: ProviderGroup;
edits: Record<string, string>; edits: Record<string, string>;
@ -271,6 +276,7 @@ function ProviderGroupCard({
onClear: (key: string) => void; onClear: (key: string) => void;
onReveal: (key: string) => void; onReveal: (key: string) => void;
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const { t } = useI18n(); const { t } = useI18n();
@ -325,6 +331,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/> />
))} ))}
{/* Base URLs (secondary) */} {/* Base URLs (secondary) */}
@ -333,6 +340,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/> />
))} ))}
{/* Anything else */} {/* Anything else */}
@ -341,6 +349,7 @@ function ProviderGroupCard({
key={key} varKey={key} info={info} compact key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/> />
))} ))}
</div> </div>
@ -390,24 +399,30 @@ export default function EnvPage() {
} }
}; };
const handleClear = async (key: string) => { const keyClear = useConfirmDelete({
setSaving(key); onDelete: useCallback(
try { async (key: string) => {
await api.deleteEnvVar(key); setSaving(key);
setVars((prev) => try {
prev await api.deleteEnvVar(key);
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } } setVars((prev) =>
: prev, prev
); ? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; }); : prev,
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; }); );
showToast(`${key} ${t.common.removed}`, "success"); setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
} catch (e) { setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error"); showToast(`${key} ${t.common.removed}`, "success");
} finally { } catch (e) {
setSaving(null); showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
} throw e;
}; } finally {
setSaving(null);
}
},
[showToast, t.common.removed, t.common.failedToRemove],
),
});
const handleReveal = async (key: string) => { const handleReveal = async (key: string) => {
if (revealed[key]) { if (revealed[key]) {
@ -488,10 +503,29 @@ export default function EnvPage() {
const totalProviders = providerGroups.length; const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length; const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
const pendingClearKey = keyClear.pendingId;
const pendingKeyDescription =
pendingClearKey && vars
? vars[pendingClearKey]?.description
: undefined;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Toast toast={toast} /> <Toast toast={toast} />
<DeleteConfirmDialog
open={keyClear.isOpen}
onCancel={keyClear.cancel}
onConfirm={keyClear.confirm}
title={t.env.confirmClearTitle}
description={
pendingClearKey
? `${pendingClearKey}${pendingKeyDescription ? `${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
: t.env.confirmClearMessage
}
loading={keyClear.isDeleting}
/>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -530,7 +564,8 @@ export default function EnvPage() {
key={group.name} key={group.name}
group={group} group={group}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} 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}
/> />
))} ))}
</CardContent> </CardContent>
@ -557,7 +592,8 @@ export default function EnvPage() {
<EnvVarRow <EnvVarRow
key={key} varKey={key} info={info} key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} 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}
/> />
))} ))}
@ -566,7 +602,8 @@ export default function EnvPage() {
category={category} category={category}
unsetEntries={unsetEntries} unsetEntries={unsetEntries}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} 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}
/> />
)} )}
</CardContent> </CardContent>
@ -592,6 +629,7 @@ function CollapsibleUnset({
onClear, onClear,
onReveal, onReveal,
onCancelEdit, onCancelEdit,
clearDialogOpen = false,
}: { }: {
category: string; category: string;
unsetEntries: [string, EnvVarInfo][]; unsetEntries: [string, EnvVarInfo][];
@ -603,6 +641,7 @@ function CollapsibleUnset({
onClear: (key: string) => void; onClear: (key: string) => void;
onReveal: (key: string) => void; onReveal: (key: string) => void;
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) { }) {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const { t } = useI18n(); const { t } = useI18n();
@ -625,6 +664,7 @@ function CollapsibleUnset({
key={key} varKey={key} info={info} key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving} edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit} onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/> />
))} ))}
</> </>

View file

@ -1,13 +1,14 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw, ChevronRight } from "lucide-react"; import { FileText, RefreshCw } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { FilterGroup, Segmented } from "@/components/ui/segmented";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
const FILES = ["agent", "errors", "gateway"] as const; const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const; const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
@ -34,38 +35,8 @@ const LINE_COLORS: Record<string, string> = {
debug: "text-muted-foreground/60", debug: "text-muted-foreground/60",
}; };
function SidebarHeading({ children }: { children: React.ReactNode }) { const toOptions = <T extends string>(values: readonly T[]) =>
return ( values.map((v) => ({ value: v, label: v }));
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 px-2.5 pt-3 pb-1">
{children}
</span>
);
}
function SidebarItem<T extends string>({
label,
value,
current,
onChange,
}: SidebarItemProps<T>) {
const isActive = current === value;
return (
<button
type="button"
onClick={() => onChange(value)}
className={`group flex items-center gap-2 px-2.5 py-1 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{label}</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
}
export default function LogsPage() { export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [file, setFile] = useState<(typeof FILES)[number]>("agent");
@ -79,6 +50,7 @@ export default function LogsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n(); const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const fetchLogs = useCallback(() => { const fetchLogs = useCallback(() => {
setLoading(true); setLoading(true);
@ -97,6 +69,66 @@ export default function LogsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [file, lineCount, level, component]); }, [file, lineCount, level, component]);
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<Badge variant="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={fetchLogs}
disabled={loading}
className="h-7 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
autoRefresh,
component,
file,
level,
loading,
setAfterTitle,
setEnd,
t.common.live,
t.common.refresh,
t.logs.autoRefresh,
fetchLogs,
]);
useEffect(() => { useEffect(() => {
fetchLogs(); fetchLogs();
}, [fetchLogs]); }, [fetchLogs]);
@ -109,145 +141,80 @@ export default function LogsPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ═══════════════ Header ═══════════════ */} {/* ═══════════════ Filter toolbar ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<H2 variant="sm">{t.logs.title}</H2>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<Badge variant="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Label className="text-xs">{t.logs.autoRefresh}</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={fetchLogs}
className="text-xs h-7"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t.common.refresh}
</Button>
</div>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div <div
className="flex flex-col sm:flex-row gap-4" role="toolbar"
style={{ minHeight: "calc(100vh - 180px)" }} aria-label={t.logs.title}
className="flex flex-wrap items-center gap-x-6 gap-y-2"
> >
{/* ---- Sidebar ---- */} <FilterGroup label={t.logs.file}>
<div className="sm:w-44 sm:shrink-0"> <Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5"> </FilterGroup>
<SidebarHeading>{t.logs.file}</SidebarHeading>
{FILES.map((f) => (
<SidebarItem
key={f}
label={f}
value={f}
current={file}
onChange={setFile}
/>
))}
<SidebarHeading>{t.logs.level}</SidebarHeading> <FilterGroup label={t.logs.level}>
{LEVELS.map((l) => ( <Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
<SidebarItem </FilterGroup>
key={l}
label={l}
value={l}
current={level}
onChange={setLevel}
/>
))}
<SidebarHeading>{t.logs.component}</SidebarHeading> <FilterGroup label={t.logs.component}>
{COMPONENTS.map((c) => ( <Segmented
<SidebarItem value={component}
key={c} onChange={setComponent}
label={c} options={toOptions(COMPONENTS)}
value={c} />
current={component} </FilterGroup>
onChange={setComponent}
/>
))}
<SidebarHeading>{t.logs.lines}</SidebarHeading> <FilterGroup label={t.logs.lines}>
{LINE_COUNTS.map((n) => ( <Segmented
<SidebarItem value={String(lineCount)}
key={n} onChange={(v) =>
label={String(n)} setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
value={String(n)} }
current={String(lineCount)} options={LINE_COUNTS.map((n) => ({
onChange={(v) => value: String(n),
setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) label: String(n),
} }))}
/> />
))} </FilterGroup>
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">
{t.logs.noLogLines}
</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div
key={i}
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div> </div>
{/* ═══════════════ Log viewer ═══════════════ */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">
{t.logs.noLogLines}
</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div
key={i}
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div> </div>
); );
} }
interface SidebarItemProps<T extends string> {
label: string;
value: T;
current: T;
onChange: (v: T) => void;
}

View file

@ -1,8 +1,12 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
import { import {
AlertTriangle,
CheckCircle2,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Database,
Loader2,
MessageSquare, MessageSquare,
Search, Search,
Trash2, Trash2,
@ -13,19 +17,27 @@ import {
Hash, Hash,
X, X,
} from "lucide-react"; } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { import type {
SessionInfo, SessionInfo,
SessionMessage, SessionMessage,
SessionSearchResult, SessionSearchResult,
StatusResponse,
} from "@/lib/api"; } from "@/lib/api";
import { timeAgo } from "@/lib/utils"; import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@/hooks/useToast";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{ {
@ -381,7 +393,62 @@ export default function SessionsPage() {
>(null); >(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const { toast, showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<Badge variant="secondary" className="text-xs tabular-nums">
{total}
</Badge>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pr-7 pl-8 text-xs"
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
loading,
search,
searching,
setAfterTitle,
setEnd,
t.sessions.searchPlaceholder,
total,
]);
const loadSessions = useCallback((p: number) => { const loadSessions = useCallback((p: number) => {
setLoading(true); setLoading(true);
@ -399,6 +466,24 @@ export default function SessionsPage() {
loadSessions(page); loadSessions(page);
}, [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 // Debounced FTS search
useEffect(() => { useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
@ -423,16 +508,27 @@ export default function SessionsPage() {
}; };
}, [search]); }, [search]);
const handleDelete = async (id: string) => { const sessionDelete = useConfirmDelete({
try { onDelete: useCallback(
await api.deleteSession(id); async (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id)); try {
setTotal((prev) => prev - 1); await api.deleteSession(id);
if (expandedId === id) setExpandedId(null); setSessions((prev) => prev.filter((s) => s.id !== id));
} catch { setTotal((prev) => prev - 1);
// ignore 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) // Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>(); const snippetMap = new Map<string, string>();
@ -448,6 +544,36 @@ export default function SessionsPage() {
? sessions.filter((s) => snippetMap.has(s.id)) ? sessions.filter((s) => snippetMap.has(s.id))
: sessions; : 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-center py-24">
@ -458,38 +584,159 @@ export default function SessionsPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Header outside card for lighter feel */} <Toast toast={toast} />
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="flex items-center gap-2"> <DeleteConfirmDialog
<MessageSquare className="h-5 w-5 text-muted-foreground" /> open={sessionDelete.isOpen}
<H2 variant="sm">{t.sessions.title}</H2> onCancel={sessionDelete.cancel}
<Badge variant="secondary" className="text-xs"> onConfirm={sessionDelete.confirm}
{total} title={t.sessions.confirmDeleteTitle}
</Badge> description={
pendingSession?.title && pendingSession.title !== "Untitled"
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
: t.sessions.confirmDeleteMessage
}
loading={sessionDelete.isDeleting}
/>
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div> </div>
<div className="relative w-full sm:w-64"> )}
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" /> {activeAction && (
) : ( <div className="border border-border bg-background-base/50">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
)} <div className="flex items-center gap-2 min-w-0">
<Input {actionStatus?.running ? (
placeholder={t.sessions.searchPlaceholder} <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
value={search} ) : actionStatus?.exit_code === 0 ? (
onChange={(e) => setSearch(e.target.value)} <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
className="pl-8 pr-7 h-8 text-xs" ) : actionStatus !== null ? (
/> <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
{search && ( ) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
variant={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<button <button
type="button" type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer" onClick={dismissLog}
onClick={() => setSearch("")} className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
aria-label={t.common.close}
> >
<X className="h-3 w-3" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} </div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div> </div>
</div> )}
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex flex-col gap-1 min-w-0 w-full">
<span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate">
{s.preview}
</span>
)}
</div>
<Badge
variant="outline"
className="text-[10px] shrink-0 self-start sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@ -516,7 +763,7 @@ export default function SessionsPage() {
onToggle={() => onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id)) setExpandedId((prev) => (prev === s.id ? null : s.id))
} }
onDelete={() => handleDelete(s.id)} onDelete={() => sessionDelete.requestDelete(s.id)}
/> />
))} ))}
</div> </div>

View file

@ -1,9 +1,8 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import { import {
Package, Package,
Search, Search,
Wrench, Wrench,
ChevronRight,
X, X,
Cpu, Cpu,
Globe, Globe,
@ -14,8 +13,8 @@ import {
Blocks, Blocks,
Code, Code,
Zap, Zap,
Filter,
} from "lucide-react"; } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types & helpers */ /* Types & helpers */
@ -98,6 +98,7 @@ export default function SkillsPage() {
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set()); const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => { useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()]) Promise.all([api.getSkills(), api.getToolsets()])
@ -182,6 +183,53 @@ export default function SkillsPage() {
const enabledCount = skills.filter((s) => s.enabled).length; const enabledCount = skills.filter((s) => s.enabled).length;
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
]);
const filteredToolsets = useMemo(() => { const filteredToolsets = useMemo(() => {
return toolsets.filter( return toolsets.filter(
(ts) => (ts) =>
@ -205,122 +253,98 @@ export default function SkillsPage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Toast toast={toast} /> <Toast toast={toast} />
{/* ═══════════════ Header ═══════════════ */} {/* ═══════════════ Filter panel + Content ═══════════════ */}
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="flex items-center gap-3"> {/* ---- Filter panel ---- */}
<Package className="h-5 w-5 text-muted-foreground" /> <aside
<H2 variant="sm">{t.skills.title}</H2> aria-label={t.skills.title}
<span className="text-xs text-muted-foreground"> className="sm:w-56 sm:shrink-0"
{t.skills.enabledOf >
.replace("{enabled}", String(enabledCount)) <div className="sm:sticky sm:top-4">
.replace("{total}", String(skills.length))} <div
</span> className={`
</div> flex flex-col
</div> border border-border bg-muted/20
`}
>
{/* Filter heading */}
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.skills.filters}
</span>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */} {/* View switch (Skills / Toolsets) */}
<div <div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
className="flex flex-col sm:flex-row gap-4" <PanelItem
style={{ minHeight: "calc(100vh - 180px)" }} icon={Package}
> label={`${t.skills.all} (${skills.length})`}
{/* ---- Sidebar ---- */} active={view === "skills" && !isSearching}
<div className="sm:w-52 sm:shrink-0"> onClick={() => {
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1"> setView("skills");
{/* Search */} setActiveCategory(null);
<div className="relative mb-2 hidden sm:block"> setSearch("");
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> }}
<Input />
className="pl-8 h-8 text-xs" <PanelItem
placeholder={t.common.search} icon={Wrench}
value={search} label={`${t.skills.toolsets} (${toolsets.length})`}
onChange={(e) => setSearch(e.target.value)} active={view === "toolsets"}
/> onClick={() => {
{search && ( setView("toolsets");
<button setSearch("");
type="button" }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" />
onClick={() => setSearch("")} </div>
>
<X className="h-3 w-3" /> {/* Category sub-filters (only for Skills view) */}
</button> {view === "skills" && !isSearching && allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
{allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className={`
group flex items-center gap-2 px-2 py-1
rounded-sm text-left text-[11px] cursor-pointer
transition-colors
${
isActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
}
`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
)} )}
</div> </div>
{/* Top-level nav */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.all} ({skills.length})
</span>
{view === "skills" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill categories (nested under All Skills) */}
{view === "skills" &&
!isSearching &&
allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}
>
{count}
</span>
</button>
);
})}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets"
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.toolsets} ({toolsets.length})
</span>
{view === "toolsets" && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div> </div>
</div> </aside>
{/* ---- Content ---- */} {/* ---- Content ---- */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -522,9 +546,39 @@ function SkillRow({
); );
} }
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`
group flex items-center gap-2 px-2.5 py-1.5
font-mondwest text-[0.7rem] tracking-[0.08em] uppercase
rounded-sm text-left cursor-pointer whitespace-nowrap
transition-colors
${
active
? "bg-foreground/90 text-background"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/10"
}
`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</button>
);
}
interface PanelItemProps {
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}
interface SkillRowProps { interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
skill: SkillInfo; skill: SkillInfo;
toggling: boolean; toggling: boolean;
onToggle: () => void;
noDescriptionLabel: string;
} }

View file

@ -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<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [pendingAction, setPendingAction] = useState<
"restart" | "update" | null
>(null);
const [activeAction, setActiveAction] = useState<"restart" | "update" | null>(
null,
);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const logScrollRef = useRef<HTMLPreElement | null>(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 (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
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 (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
<Grid className="border-b md:!grid-cols-2 lg:!grid-cols-4">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<Cell
key={label}
className="flex min-w-0 flex-col gap-2 overflow-hidden"
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div
className="truncate text-2xl font-bold font-mondwest"
title={value}
>
{value}
</div>
{badgeText && (
<Badge variant={badgeVariant} className="self-start">
{badgeVariant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{badgeText}
</Badge>
)}
</Cell>
))}
<Cell className="flex min-w-0 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
{t.status.actions}
</CardTitle>
<Wrench className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex gap-4">
<Button
variant="outline"
size="sm"
onClick={() => runAction("restart")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
className="flex-1 min-w-0"
>
<RotateCw
className={cn(
"h-3.5 w-3.5",
(pendingAction === "restart" ||
(activeAction === "restart" && actionStatus?.running)) &&
"animate-spin",
)}
/>
{activeAction === "restart" && actionStatus?.running
? t.status.restartingGateway
: t.status.restartGateway}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runAction("update")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
className="flex-1 min-w-0"
>
<Download
className={cn(
"h-3.5 w-3.5",
(pendingAction === "update" ||
(activeAction === "update" && actionStatus?.running)) &&
"animate-pulse",
)}
/>
{activeAction === "update" && actionStatus?.running
? t.status.updatingHermes
: t.status.updateHermes}
</Button>
</div>
</Cell>
</Grid>
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
variant={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<button
type="button"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
aria-label={t.common.close}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
{platforms.length > 0 && (
<PlatformsCard
platforms={platforms}
platformStateBadge={PLATFORM_STATE_BADGE}
/>
)}
{activeSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">
{t.status.activeSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{activeSessions.map((s) => (
<div
key={s.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex flex-col gap-1 min-w-0 w-full">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
</div>
<span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex flex-col gap-1 min-w-0 w-full">
<span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate">
{s.preview}
</span>
)}
</div>
<Badge
variant="outline"
className="text-[10px] shrink-0 self-start sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
const { t } = useI18n();
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{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 (
<div
key={name}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
>
<div className="flex items-center gap-3 min-w-0 w-full">
<IconComponent
className={`h-4 w-4 shrink-0 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge
variant={display.variant}
className="shrink-0 self-start sm:self-center"
>
{display.variant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
platformStateBadge: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
>;
}

View file

@ -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 <Component />;
}
if (loadError) {
const message = formatPluginError(loadError, t);
return (
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
)}
role="alert"
>
{message}
</div>
);
}
return (
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
)}
>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<span>{t.common.loading}</span>
</div>
);
}
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;
}

View file

@ -1,4 +1,5 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry"; export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { PluginPage } from "./PluginPage";
export { usePlugins } from "./usePlugins"; export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots"; export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots"; export type { KnownSlotName } from "./slots";

View file

@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots";
type RegistryListener = () => void; type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map(); const _registered: Map<string, React.ComponentType> = new Map();
const _loadErrors: Map<string, string> = new Map();
const _listeners: Set<RegistryListener> = new Set(); const _listeners: Set<RegistryListener> = new Set();
function _notify() { 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. */ /** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) { function registerPlugin(name: string, component: React.ComponentType) {
_loadErrors.delete(name);
_registered.set(name, component); _registered.set(name, component);
_notify(); _notify();
} }
@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine
return _registered.get(name); 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). */ /** Subscribe to registry changes (returns unsubscribe fn). */
export function onPluginRegistered(fn: RegistryListener): () => void { export function onPluginRegistered(fn: RegistryListener): () => void {
_listeners.add(fn); _listeners.add(fn);

View file

@ -1,5 +1,7 @@
/** Types for the dashboard plugin system. */ /** Types for the dashboard plugin system. */
import type { ComponentType } from "react";
export interface PluginManifest { export interface PluginManifest {
name: string; name: string;
label: string; label: string;
@ -8,21 +10,14 @@ export interface PluginManifest {
version: string; version: string;
tab: { tab: {
path: string; path: string;
position: string; // "end", "after:<tab>", "before:<tab>" /** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this position?: string;
* plugin's component replaces the built-in page at that route rather /** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
* than adding a new tab. Useful for themes that want a custom home
* page without losing the rest of the dashboard. */
override?: string; override?: string;
/** When true, the plugin registers its component and slot contributors /** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
* plugin that just injects a header crest). */
hidden?: boolean; hidden?: boolean;
}; };
/** Named shell slots this plugin populates. Mirrored by the backend's /** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
* 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)`. */
slots?: string[]; slots?: string[];
entry: string; entry: string;
css?: string | null; css?: string | null;
@ -32,5 +27,5 @@ export interface PluginManifest {
export interface RegisteredPlugin { export interface RegisteredPlugin {
manifest: PluginManifest; manifest: PluginManifest;
component: React.ComponentType; component: ComponentType;
} }

View file

@ -10,7 +10,12 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types"; import type { PluginManifest, RegisteredPlugin } from "./types";
import { getPluginComponent, onPluginRegistered } from "./registry"; import {
getPluginComponent,
onPluginRegistered,
notifyPluginRegistry,
setPluginLoadError,
} from "./registry";
export function usePlugins() { export function usePlugins() {
const [manifests, setManifests] = useState<PluginManifest[]>([]); const [manifests, setManifests] = useState<PluginManifest[]>([]);
@ -33,6 +38,8 @@ export function usePlugins() {
useEffect(() => { useEffect(() => {
if (manifests.length === 0) return; if (manifests.length === 0) return;
const injectedScripts: HTMLScriptElement[] = [];
for (const manifest of manifests) { for (const manifest of manifests) {
// Inject CSS if specified. // Inject CSS if specified.
if (manifest.css) { if (manifest.css) {
@ -45,23 +52,49 @@ export function usePlugins() {
} }
} }
// Load JS bundle. // Load JS bundle. In dev, cache-bust so Vite HMR can clear the
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`; // in-memory registry while the browser would otherwise never
if (loadedScripts.current.has(jsUrl)) continue; // re-execute a previously cached <script> URL.
loadedScripts.current.add(jsUrl); const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script"); const script = document.createElement("script");
script.src = jsUrl; script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true; script.async = true;
script.onerror = () => { script.onerror = () => {
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`); setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
}; };
document.body.appendChild(script); document.body.appendChild(script);
injectedScripts.push(script);
} }
// Give plugins a moment to load and register, then stop loading state. // Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000); const timeout = setTimeout(() => setLoading(false), 2000);
return () => clearTimeout(timeout); return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [manifests]); }, [manifests]);
// Listen for plugin registrations and resolve them against manifests. // Listen for plugin registrations and resolve them against manifests.

View file

@ -65,6 +65,10 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
"/api": BACKEND, "/api": BACKEND,
// Same host as `hermes dashboard` must serve these; Vite has no
// dashboard-plugins/* files, so without this, plugin scripts 404
// or receive index.html in dev.
"/dashboard-plugins": BACKEND,
}, },
}, },
}); });