Merge pull request #17007 from NousResearch/austin/fix/more-design-system

fix: replace all buttons for design system buttons
This commit is contained in:
Austin Pickett
2026-04-28 11:46:47 -07:00
committed by GitHub
36 changed files with 1271 additions and 1429 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-4Z8KQ69QhO83X6zff+5urWBv6MME686MhTTMdwSl65o=";
hash = "sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
+91 -76
View File
@@ -8,7 +8,7 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@nous-research/ui": "^0.4.0",
"@nous-research/ui": "^0.10.0",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
@@ -26,7 +26,8 @@
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -1078,9 +1079,9 @@
}
},
"node_modules/@nous-research/ui": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.4.0.tgz",
"integrity": "sha512-wA9YImWLFjx3yWsb3TsquwG9VKZunupdovkOjnRboFjNAb3Jcf57o67xWafEPEm3VX6k6RP/+Y9zHWX0PUtZ4w==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.10.0.tgz",
"integrity": "sha512-gzB7rjzW4F9C1YkILR9EvCk6Ul6cWhqEeb2HzuRJK4NiC1gHeQ2D2Pr+15qbMghV4SuTLJmwLSLvbH76nRA5Jw==",
"license": "MIT",
"dependencies": {
"@nanostores/react": "^1.0.0",
@@ -1089,7 +1090,8 @@
"nanostores": "^1.0.1",
"sanitize-html": "^2.16.0",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"unicode-animations": "^1.0.3"
},
"peerDependencies": {
"@observablehq/plot": "^0.6.17",
@@ -2524,17 +2526,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
"integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/type-utils": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/type-utils": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -2547,7 +2549,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/parser": "^8.59.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -2563,17 +2565,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -2589,14 +2591,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
"integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.0",
"@typescript-eslint/types": "^8.59.0",
"@typescript-eslint/tsconfig-utils": "^8.59.1",
"@typescript-eslint/types": "^8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -2611,14 +2613,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
"integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0"
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2629,9 +2631,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
"integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2646,15 +2648,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
"integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -2671,9 +2673,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
"integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2685,16 +2687,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
"integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.0",
"@typescript-eslint/tsconfig-utils": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"@typescript-eslint/project-service": "8.59.1",
"@typescript-eslint/tsconfig-utils": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2765,16 +2767,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
"integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0"
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2789,13 +2791,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
"integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/types": "8.59.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3001,9 +3003,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
"integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3100,9 +3102,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
"integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"dev": true,
"funding": [
{
@@ -5134,9 +5136,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"funding": [
{
"type": "opencollective",
@@ -5653,16 +5655,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
"integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz",
"integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0"
"@typescript-eslint/eslint-plugin": "8.59.1",
"@typescript-eslint/parser": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5683,6 +5685,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/unicode-animations": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz",
"integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"unicode-animations": "^1.0.1"
},
"bin": {
"unicode-animations": "scripts/demo.cjs"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+3 -2
View File
@@ -13,7 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
"@nous-research/ui": "^0.4.0",
"@nous-research/ui": "^0.10.0",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
@@ -31,7 +31,8 @@
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+56 -78
View File
@@ -27,7 +27,6 @@ import {
Globe,
Heart,
KeyRound,
Loader2,
Menu,
MessageSquare,
Package,
@@ -42,7 +41,13 @@ import {
X,
Zap,
} from "lucide-react";
import { SelectionSwitcher, Typography } from "@nous-research/ui";
import {
Button,
ListItem,
SelectionSwitcher,
Spinner,
Typography,
} from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
@@ -160,7 +165,10 @@ function resolveIcon(name: string): ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle;
}
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
function buildNavItems(
builtIn: NavItem[],
manifests: PluginManifest[],
): NavItem[] {
const items = [...builtIn];
for (const manifest of manifests) {
@@ -367,20 +375,17 @@ export default function App() {
clipPath: "var(--component-header-clip-path)",
}}
>
<button
type="button"
<Button
ghost
size="icon"
onClick={() => setMobileOpen(true)}
aria-label={t.app.openNavigation}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
className={cn(
"inline-flex h-8 w-8 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
className="text-midground/70 hover:text-midground"
>
<Menu className="h-4 w-4" />
</button>
<Menu />
</Button>
<Typography
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
@@ -391,13 +396,13 @@ export default function App() {
</header>
{mobileOpen && (
<button
type="button"
<Button
ghost
aria-label={t.app.closeNavigation}
onClick={closeMobile}
className={cn(
"lg:hidden fixed inset-0 z-40",
"bg-black/60 backdrop-blur-sm cursor-pointer",
"lg:hidden fixed inset-0 z-40 p-0 block",
"bg-black/60 backdrop-blur-sm",
)}
/>
)}
@@ -425,35 +430,34 @@ export default function App() {
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 px-5",
"flex h-14 shrink-0 items-center justify-between gap-2",
"border-b border-current/20",
)}
>
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
<div className="flex items-center gap-2">
<PluginSlot name="header-left" />
<button
type="button"
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
</div>
<Button
ghost
size="icon"
onClick={closeMobile}
aria-label={t.app.closeNavigation}
className={cn(
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
className="lg:hidden text-midground/70 hover:text-midground"
>
<X className="h-4 w-4" />
</button>
<X />
</Button>
</div>
<PluginSlot name="header-left" />
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
@@ -545,7 +549,8 @@ export default function App() {
<div
className={cn(
"w-full min-w-0",
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
(isDocsRoute || isChatRoute) &&
"min-h-0 flex flex-1 flex-col",
)}
>
<Routes>
@@ -558,34 +563,9 @@ export default function App() {
/>
</Routes>
{/*
Persistent chat host: always mounted when `hermes dashboard
--tui` is active, visibility toggled by route. Keeping the
tree alive preserves the xterm instance, its WebSocket, and
the PTY child that backs the TUI session — so navigating to
another tab and returning lands the user in the same
conversation instead of spawning a fresh session.
The host sits alongside <Routes> (not inside one) because
React Router unmounts route elements on path change, which
is exactly the destructive lifecycle we're avoiding.
Trade-off worth knowing about: while hidden, ChatPage still
holds a PTY child + WebSocket + xterm instance for the
dashboard's full lifetime. The WS keeps delivering bytes
and xterm keeps parsing them into a display:none host
(cheap — no paint work, but not free). If this becomes a
resource problem we can pause `term.write` when !isActive
or idle-disconnect after N minutes hidden; neither is
shipped today.
*/}
{embeddedChat && !chatOverriddenByPlugin && (
pluginsLoading ? (
// Direct /chat deep-link: plugin manifests haven't resolved
// yet, so we can't tell if a plugin is going to claim this
// route. Show a lightweight placeholder instead of a
// blank page. Typical wait is <50ms; worst case is the
// 2s plugin-registration safety timeout.
{embeddedChat &&
!chatOverriddenByPlugin &&
(pluginsLoading ? (
isChatRoute ? (
<div
className="flex min-h-0 min-w-0 flex-1 items-center justify-center"
@@ -593,7 +573,7 @@ export default function App() {
aria-live="polite"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
<Spinner />
<span>Loading chat</span>
</div>
</div>
@@ -609,8 +589,7 @@ export default function App() {
>
<ChatPage isActive={isChatRoute} />
</div>
)
)}
))}
</div>
<PluginSlot name="post-main" />
</div>
@@ -683,30 +662,29 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
return (
<li key={action}>
<button
type="button"
<ListItem
onClick={() => handleClick(action)}
disabled={disabled}
aria-busy={busy}
active={busy}
className={cn(
"group relative flex w-full items-center gap-3",
"px-5 py-1.5",
"gap-3 px-5 py-1.5 whitespace-nowrap",
"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",
"transition-opacity",
busy
? "text-midground opacity-100"
: "opacity-60 hover:opacity-100",
"disabled:cursor-not-allowed disabled:opacity-30",
"disabled:opacity-30",
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
<Spinner className="shrink-0 text-[0.875rem]" />
) : isActionRunning && spin ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && spin && "animate-spin",
isActionRunning && !spin && "animate-pulse",
)}
/>
@@ -726,7 +704,7 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
</ListItem>
</li>
);
})}
+1 -2
View File
@@ -1,7 +1,6 @@
import { Select, SelectOption, Switch } from "@nous-research/ui";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectOption } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
+4 -6
View File
@@ -44,18 +44,16 @@ export function Backdrop() {
// `assets.bg` — the <img> hides itself when a CSS bg is set
// so the two don't double-darken. CSS var fallbacks keep the
// default behaviour unchanged when no theme customises these.
mixBlendMode: "var(--component-backdrop-filler-blend-mode, difference)",
mixBlendMode:
"var(--component-backdrop-filler-blend-mode, difference)",
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
backgroundImage: "var(--theme-asset-bg)",
backgroundSize: "var(--component-backdrop-background-size, cover)",
backgroundPosition: "var(--component-backdrop-background-position, center)",
backgroundPosition:
"var(--component-backdrop-background-position, center)",
} as unknown as React.CSSProperties
}
>
{/* Default filler image only renders when no theme-asset-bg is
set. Themes that provide their own `assets.bg` override the
<div>'s backgroundImage above, so hiding the <img> in that
case prevents the two from compositing incorrectly. */}
<img
alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
+25 -20
View File
@@ -23,8 +23,8 @@
* terminal pane keeps working unimpaired.
*/
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button } from "@nous-research/ui";
import { Badge } from "@nous-research/ui";
import { Card } from "@/components/ui/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
@@ -57,12 +57,15 @@ const STATE_LABEL: Record<ConnectionState, string> = {
error: "error",
};
const STATE_TONE: Record<ConnectionState, string> = {
idle: "bg-muted text-muted-foreground",
connecting: "bg-primary/10 text-primary",
open: "bg-emerald-500/10 text-emerald-500 dark:text-emerald-400",
closed: "bg-muted text-muted-foreground",
error: "bg-destructive/10 text-destructive",
const STATE_TONE: Record<
ConnectionState,
"secondary" | "warning" | "success" | "destructive"
> = {
idle: "secondary",
connecting: "warning",
open: "success",
closed: "secondary",
error: "destructive",
};
interface ChatSidebarProps {
@@ -310,22 +313,24 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
model
</div>
<button
type="button"
<Button
ghost
size="sm"
disabled={!canPickModel}
onClick={() => setModelOpen(true)}
className="flex items-center gap-1 truncate text-sm font-medium hover:underline disabled:cursor-not-allowed disabled:opacity-60 disabled:no-underline"
suffix={
canPickModel ? (
<ChevronDown className="opacity-60" />
) : undefined
}
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>
{canPickModel && (
<ChevronDown className="h-3 w-3 shrink-0 opacity-60" />
)}
</button>
</Button>
</div>
<Badge className={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
<Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
</Card>
{banner && (
@@ -337,12 +342,12 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
{error && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-6 px-1.5 text-xs"
outlined
className="mt-1"
onClick={reconnect}
prefix={<RefreshCw />}
>
<RefreshCw className="mr-1 h-3 w-3" />
reconnect
</Button>
)}
+16 -14
View File
@@ -1,4 +1,4 @@
import { Typography } from "@nous-research/ui";
import { Button, Typography } from "@nous-research/ui";
import { useI18n } from "@/i18n/context";
/**
@@ -11,23 +11,25 @@ export function LanguageSwitcher() {
const toggle = () => setLocale(locale === "en" ? "zh" : "en");
return (
<button
type="button"
<Button
ghost
onClick={toggle}
className="group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
title={t.language.switchTo}
aria-label={t.language.switchTo}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
>
{/* Show the *current* language's flag — tooltip advertises the click action */}
<span className="text-base leading-none">
{locale === "en" ? "🇬🇧" : "🇨🇳"}
<span className="inline-flex items-center gap-1.5">
<span className="text-base leading-none">
{locale === "en" ? "🇬🇧" : "🇨🇳"}
</span>
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{locale === "en" ? "EN" : "中文"}
</Typography>
</span>
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{locale === "en" ? "EN" : "中文"}
</Typography>
</button>
</Button>
);
}
+10 -14
View File
@@ -1,12 +1,6 @@
import { useEffect, useRef, useState } from "react";
import {
Brain,
Eye,
Gauge,
Lightbulb,
Wrench,
Loader2,
} from "lucide-react";
import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
@@ -18,7 +12,10 @@ interface ModelInfoCardProps {
refreshKey?: number;
}
export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardProps) {
export function ModelInfoCard({
currentModel,
refreshKey = 0,
}: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
@@ -40,7 +37,7 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<Spinner className="text-xs" />
Loading model info
</div>
);
@@ -53,7 +50,6 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
return (
<div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
{/* Context window */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
@@ -68,12 +64,13 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-muted-foreground/60 text-[10px]">auto-detected</span>
<span className="text-muted-foreground/60 text-[10px]">
auto-detected
</span>
)}
</div>
</div>
{/* Max output */}
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
@@ -86,7 +83,6 @@ export function ModelInfoCard({ currentModel, refreshKey = 0 }: ModelInfoCardPro
</div>
)}
{/* Capability badges */}
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
+20 -25
View File
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import { Button, ListItem, Spinner } from "@nous-research/ui";
import { Input } from "@/components/ui/input";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Loader2, Search, X } from "lucide-react";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
/**
@@ -145,14 +145,15 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
aria-labelledby="model-picker-title"
>
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
<button
type="button"
<Button
ghost
size="icon"
onClick={onClose}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
@@ -222,10 +223,10 @@ export function ModelPickerDialog({ gw, sessionId, onClose, onSubmit }: Props) {
</label>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="sm" onClick={onClose}>
<Button outlined onClick={onClose}>
Cancel
</Button>
<Button size="sm" onClick={confirm} disabled={!canConfirm}>
<Button onClick={confirm} disabled={!canConfirm}>
Switch
</Button>
</div>
@@ -260,7 +261,7 @@ function ProviderColumn({
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> loading
<Spinner className="text-xs" /> loading
</div>
)}
@@ -279,14 +280,12 @@ function ProviderColumn({
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<button
<ListItem
key={p.slug}
type="button"
active={active}
onClick={() => onSelect(p.slug)}
className={`w-full text-left px-3 py-2 text-xs border-l-2 transition-colors cursor-pointer flex items-start gap-2 ${
active
? "bg-primary/10 border-l-primary text-foreground"
: "border-l-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40"
className={`items-start text-xs border-l-2 ${
active ? "border-l-primary" : "border-l-transparent"
}`}
>
<div className="flex-1 min-w-0">
@@ -298,7 +297,7 @@ function ProviderColumn({
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</button>
</ListItem>
);
})}
</div>
@@ -359,23 +358,19 @@ function ModelColumn({
m === currentModel && provider.slug === currentProviderSlug;
return (
<button
<ListItem
key={m}
type="button"
active={active}
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors cursor-pointer flex items-center gap-2 ${
active
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
className="px-3 py-1.5 text-xs font-mono"
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">{m}</span>
{isCurrent && <CurrentTag />}
</button>
</ListItem>
);
})
)}
+22 -57
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { ExternalLink, X, Check } from "lucide-react";
import { Button, CopyButton, H2, Spinner } from "@nous-research/ui";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
@@ -22,18 +21,12 @@ type Phase =
| "approved"
| "error";
export function OAuthLoginModal({
provider,
onClose,
onSuccess,
onError,
}: Props) {
export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const [codeCopied, setCodeCopied] = useState(false);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);
const { t } = useI18n();
@@ -154,16 +147,6 @@ export function OAuthLoginModal({
onClose();
};
const handleCopyUserCode = async (code: string) => {
try {
await navigator.clipboard.writeText(code);
setCodeCopied(true);
window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500);
} catch {
onError("Clipboard write failed");
}
};
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose();
};
@@ -184,14 +167,15 @@ export function OAuthLoginModal({
aria-labelledby="oauth-modal-title"
>
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
<button
type="button"
<Button
ghost
size="icon"
onClick={handleClose}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label={t.common.close}
>
<X className="h-5 w-5" />
</button>
<X />
</Button>
<div className="p-6 flex flex-col gap-4">
<div>
<H2
@@ -214,15 +198,13 @@ export function OAuthLoginModal({
)}
</div>
{/* ── starting ───────────────────────────────────── */}
{phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
{t.oauth.initiatingLogin}
</div>
)}
{/* ── PKCE: paste code ───────────────────────────── */}
{start?.flow === "pkce" && phase === "awaiting_user" && (
<>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
@@ -254,7 +236,6 @@ export function OAuthLoginModal({
<Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
size="sm"
>
{t.oauth.submitCode}
</Button>
@@ -263,15 +244,13 @@ export function OAuthLoginModal({
</>
)}
{/* ── PKCE: submitting exchange ──────────────────── */}
{phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
{t.oauth.exchangingCode}
</div>
)}
{/* ── Device code: show code + URL, polling ──────── */}
{start?.flow === "device_code" && phase === "polling" && (
<>
<p className="text-sm text-muted-foreground">
@@ -288,27 +267,16 @@ export function OAuthLoginModal({
).user_code
}
</code>
<Button
variant="outline"
size="sm"
onClick={() =>
handleCopyUserCode(
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code,
)
<CopyButton
text={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
className="text-xs"
>
{codeCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
/>
</div>
<a
href={
@@ -327,13 +295,12 @@ export function OAuthLoginModal({
{t.oauth.reOpenVerification}
</a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Loader2 className="h-3 w-3 animate-spin" />
<Spinner className="text-xs" />
{t.oauth.waitingAuth}
</div>
</>
)}
{/* ── approved ───────────────────────────────────── */}
{phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" />
@@ -341,18 +308,16 @@ export function OAuthLoginModal({
</div>
)}
{/* ── error ──────────────────────────────────────── */}
{phase === "error" && (
<>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || t.oauth.loginFailed}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleClose}>
<Button outlined onClick={handleClose}>
{t.common.close}
</Button>
<Button
size="sm"
onClick={() => {
if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {});
+58 -61
View File
@@ -1,9 +1,23 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react";
import {
ShieldCheck,
ShieldOff,
ExternalLink,
RefreshCw,
LogOut,
Terminal,
LogIn,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button, CopyButton, Spinner } from "@nous-research/ui";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@nous-research/ui";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
@@ -12,7 +26,10 @@ interface Props {
onSuccess?: (msg: string) => void;
}
function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null {
function formatExpiresAt(
expiresAt: string | null | undefined,
expiresInTemplate: string,
): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
@@ -35,7 +52,6 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const { t } = useI18n();
@@ -55,17 +71,6 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
refresh();
}, [refresh]);
const handleCopy = async (provider: OAuthProvider) => {
try {
await navigator.clipboard.writeText(provider.cli_command);
setCopiedId(provider.id);
onSuccess?.(`Copied: ${provider.cli_command}`);
setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500);
} catch {
onError?.("Clipboard write failed — copy the command manually");
}
};
const handleDisconnect = async (provider: OAuthProvider) => {
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
return;
@@ -82,7 +87,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
}
};
const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0;
const connectedCount =
providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
@@ -91,27 +97,30 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.oauth.providerLogins}</CardTitle>
<CardTitle className="text-base">
{t.oauth.providerLogins}
</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
outlined
onClick={refresh}
disabled={loading}
className="text-xs"
prefix={loading ? <Spinner /> : <RefreshCw />}
>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
{t.common.refresh}
</Button>
</div>
<CardDescription>
{t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))}
{t.oauth.description
.replace("{connected}", String(connectedCount))
.replace("{total}", String(totalCount))}
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<Spinner className="text-xl text-primary" />
</div>
)}
{providers && providers.length === 0 && (
@@ -121,14 +130,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(p.status.expires_at, t.oauth.expiresIn);
const expiresLabel = formatExpiresAt(
p.status.expires_at,
t.oauth.expiresIn,
);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
{/* Left: status icon + name + source */}
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
@@ -138,32 +149,36 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
<Badge
tone="outline"
className="text-[11px] uppercase tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge variant="success" className="text-[11px]">
<Badge tone="success" className="text-[11px]">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge variant="destructive" className="text-[11px]">
<Badge tone="destructive" className="text-[11px]">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge variant="outline" className="text-[11px]">
<Badge tone="outline" className="text-[11px]">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<code className="text-xs font-mono-ui truncate">
<span className="opacity-50">token{" "}</span>
<span className="opacity-50">token </span>
{p.status.token_preview}
{p.status.source_label && (
<span className="opacity-40">
{" "}· {p.status.source_label}
{" "}
· {p.status.source_label}
</span>
)}
</code>
@@ -184,7 +199,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)}
</div>
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
@@ -194,53 +209,35 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<ExternalLink className="h-3.5 w-3.5" />
<Button ghost size="icon">
<ExternalLink />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
variant="default"
size="sm"
onClick={() => setLoginFor(p)}
className="text-xs h-7"
prefix={<LogIn />}
>
<LogIn className="h-3 w-3 mr-1" />
{t.oauth.login}
</Button>
)}
{!p.status.logged_in && (
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(p)}
className="text-xs h-7"
title={t.oauth.copyCliCommand}
>
{copiedId === p.id ? (
<>{t.oauth.copied}</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
{t.oauth.cli}
</>
)}
</Button>
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
variant="outline"
size="sm"
outlined
onClick={() => handleDisconnect(p)}
disabled={isBusy}
className="text-xs h-7"
prefix={isBusy ? <Spinner /> : <LogOut />}
>
{isBusy ? (
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
) : (
<LogOut className="h-3 w-3 mr-1" />
)}
{t.oauth.disconnect}
</Button>
)}
+8 -8
View File
@@ -1,7 +1,7 @@
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 { Badge } from "@nous-research/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useI18n } from "@/i18n";
@@ -9,11 +9,11 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
const { t } = useI18n();
const platformStateBadge: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
{ tone: "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 },
connected: { tone: "success", label: t.status.connected },
disconnected: { tone: "warning", label: t.status.disconnected },
fatal: { tone: "destructive", label: t.status.error },
};
return (
@@ -30,7 +30,7 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = platformStateBadge[info.state] ?? {
variant: "outline" as const,
tone: "outline" as const,
label: info.state,
};
const IconComponent =
@@ -76,10 +76,10 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
</div>
<Badge
variant={display.variant}
tone={display.tone}
className="shrink-0 self-start sm:self-center"
>
{display.variant === "success" && (
{display.tone === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
+1 -1
View File
@@ -17,7 +17,7 @@ export function SidebarFooter() {
>
<Typography
mondwest
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70"
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70 lowercase"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
+5 -8
View File
@@ -1,4 +1,5 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
@@ -139,18 +140,14 @@ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
const active = i === selected;
return (
<button
<ListItem
key={`${it.text}-${i}`}
type="button"
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors ${
active
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted/60"
}`}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
@@ -165,7 +162,7 @@ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
{it.meta}
</span>
)}
</button>
</ListItem>
);
})}
</div>
+19 -24
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Palette, Check } from "lucide-react";
import { Typography } from "@nous-research/ui";
import { Button, ListItem, Typography } from "@nous-research/ui";
import { BUILTIN_THEMES, useTheme } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
@@ -50,27 +50,26 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
<Button
ghost
onClick={() => setOpen((o) => !o)}
className={cn(
"group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs",
"text-muted-foreground hover:text-foreground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
>
<Palette className="h-3.5 w-3.5" />
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{label}
</Typography>
</button>
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{label}
</Typography>
</span>
</Button>
{open && (
<div
@@ -97,20 +96,16 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
const preset = BUILTIN_THEMES[th.name];
return (
<button
<ListItem
key={th.name}
type="button"
active={isActive}
role="option"
aria-selected={isActive}
onClick={() => {
setTheme(th.name);
close();
}}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer",
"hover:bg-midground/10",
isActive ? "text-midground" : "text-midground/60",
)}
className="gap-3"
>
{preset ? (
<ThemeSwatch theme={preset.name} />
@@ -138,7 +133,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
isActive ? "opacity-100" : "opacity-0",
)}
/>
</button>
</ListItem>
);
})}
</div>
+4 -4
View File
@@ -1,3 +1,4 @@
import { ListItem } from "@nous-research/ui";
import {
AlertCircle,
Check,
@@ -87,12 +88,11 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
<div
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
>
<button
type="button"
<ListItem
onClick={() => setUserOverride(!open)}
disabled={!hasBody}
aria-expanded={open}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-foreground/2 disabled:cursor-default cursor-pointer transition-colors"
className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default"
>
{hasBody ? (
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
@@ -132,7 +132,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
{elapsed}
</span>
)}
</button>
</ListItem>
{open && hasBody && (
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
-29
View File
@@ -1,29 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center border px-2 py-0.5 font-compressed text-[0.65rem] tracking-[0.15em] uppercase transition-colors",
{
variants: {
variant: {
default: "border-foreground/20 bg-foreground/10 text-foreground",
secondary: "border-border bg-secondary text-secondary-foreground",
destructive: "border-destructive/30 bg-destructive/15 text-destructive",
outline: "border-border text-muted-foreground",
success: "grain border-emerald-600/30 bg-emerald-950/70 text-emerald-400",
warning: "border-warning/30 bg-warning/15 text-warning",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Badge({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
-38
View File
@@ -1,38 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
+ " disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-foreground/90 text-background hover:bg-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-foreground/10 hover:text-foreground",
link: "text-foreground underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-[0.65rem]",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export function Button({
className,
variant,
size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+3 -5
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react";
import { Button } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export function ConfirmDialog({
cancelLabel = "Cancel",
@@ -101,8 +101,7 @@ export function ConfirmDialog({
<div className="flex items-center justify-end gap-2 p-3">
<Button
type="button"
variant="ghost"
size="sm"
outlined
onClick={onCancel}
disabled={loading}
>
@@ -111,8 +110,7 @@ export function ConfirmDialog({
<Button
data-confirm
type="button"
variant={destructive ? "destructive" : "default"}
size="sm"
destructive={destructive}
onClick={onConfirm}
disabled={loading}
>
-80
View File
@@ -1,80 +0,0 @@
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;
}
-194
View File
@@ -1,194 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function Select({
value,
onValueChange,
children,
className,
id,
disabled,
}: SelectProps) {
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const options: SelectOptionData[] = [];
flattenChildren(children, options);
const selectedOption = options.find((o) => o.value === value);
const displayLabel = selectedOption?.label ?? value ?? "";
const close = useCallback(() => {
setOpen(false);
setHighlightedIndex(-1);
}, []);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, close]);
useEffect(() => {
if (open && listRef.current && highlightedIndex >= 0) {
const el = listRef.current.children[highlightedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}
}, [open, highlightedIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (!open) {
setOpen(true);
setHighlightedIndex(options.findIndex((o) => o.value === value));
} else if (highlightedIndex >= 0 && options[highlightedIndex]) {
onValueChange?.(options[highlightedIndex].value);
close();
}
break;
case "ArrowDown":
e.preventDefault();
if (!open) {
setOpen(true);
setHighlightedIndex(options.findIndex((o) => o.value === value));
} else {
setHighlightedIndex((i) => Math.min(i + 1, options.length - 1));
}
break;
case "ArrowUp":
e.preventDefault();
if (open) {
setHighlightedIndex((i) => Math.max(i - 1, 0));
}
break;
case "Escape":
e.preventDefault();
close();
break;
}
};
return (
<div ref={containerRef} className={cn("relative", className)} id={id}>
<button
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
onClick={() => !disabled && setOpen((o) => !o)}
onKeyDown={handleKeyDown}
className={cn(
"flex h-9 w-full items-center justify-between border border-border bg-background/40 px-3 py-1 font-courier text-sm text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer",
)}
>
<span className={cn("truncate", !selectedOption && "text-muted-foreground")}>
{displayLabel}
</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<div
ref={listRef}
role="listbox"
className={cn(
"absolute z-50 mt-1 w-full border border-border bg-popover text-popover-foreground shadow-lg",
"max-h-60 overflow-auto",
"animate-[fade-in_100ms_ease-out]",
)}
>
{options.map((opt, i) => {
const isSelected = opt.value === value;
const isHighlighted = i === highlightedIndex;
return (
<div
key={opt.value}
role="option"
aria-selected={isSelected}
onMouseEnter={() => setHighlightedIndex(i)}
onClick={() => {
onValueChange?.(opt.value);
close();
}}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-courier cursor-pointer transition-colors",
isHighlighted && "bg-foreground/10",
isSelected && "text-foreground",
!isSelected && "text-muted-foreground",
)}
>
<Check
className={cn(
"h-3.5 w-3.5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{opt.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
export function SelectOption(_props: SelectOptionProps) {
return null;
}
function flattenChildren(children: React.ReactNode, out: SelectOptionData[]) {
const arr = Array.isArray(children) ? children : [children];
for (const child of arr) {
if (!child || typeof child !== "object" || !("props" in child)) continue;
const props = child.props as Record<string, unknown>;
if (props.value !== undefined) {
out.push({
value: String(props.value),
label: typeof props.children === "string" ? props.children : String(props.value),
});
} else if (props.children) {
flattenChildren(props.children as React.ReactNode, out);
}
}
}
interface SelectProps {
value?: string;
onValueChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
id?: string;
disabled?: boolean;
}
interface SelectOptionProps {
value: string;
children: React.ReactNode;
}
interface SelectOptionData {
value: string;
label: string;
}
-40
View File
@@ -1,40 +0,0 @@
import { cn } from "@/lib/utils";
export function Switch({
checked,
onCheckedChange,
className,
disabled,
id,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
className?: string;
disabled?: boolean;
id?: string;
}) {
return (
<button
type="button"
id={id}
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border border-border transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-foreground/15 border-foreground/30" : "bg-background",
className,
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block h-3.5 w-3.5 transition-transform",
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
)}
/>
</button>
);
}
-51
View File
@@ -1,51 +0,0 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
export function Tabs({
defaultValue,
children,
className,
}: {
defaultValue: string;
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
className?: string;
}) {
const [active, setActive] = useState(defaultValue);
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
}
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"inline-flex h-9 items-center justify-start border-b border-border text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function TabsTrigger({
active,
value,
onClick,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
return (
<button
type="button"
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-mondwest text-xs tracking-[0.1em] uppercase transition-all cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-foreground"
: "hover:text-foreground",
className,
)}
onClick={onClick}
{...props}
/>
);
}
+186 -123
View File
@@ -1,18 +1,16 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import {
BarChart3,
Brain,
Cpu,
Hash,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { BarChart3, Brain, Cpu, RefreshCw, TrendingUp } from "lucide-react";
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 { Button, Spinner, Stats } from "@nous-research/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Badge } from "@nous-research/ui";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@@ -40,45 +38,25 @@ function formatDate(day: string): string {
}
}
function SummaryCard({
icon: Icon,
label,
value,
sub,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
const maxTokens = Math.max(
...daily.map((d) => d.input_tokens + d.output_tokens),
1,
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
<CardTitle className="text-base">
{t.analytics.dailyTokenUsage}
</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input}
@@ -90,47 +68,63 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
</CardHeader>
<CardContent>
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
<div
className="flex items-end gap-[2px]"
style={{ height: CHART_HEIGHT_PX }}
>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
const inputH = Math.round(
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
);
const outputH = Math.round(
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
);
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
<div>{t.analytics.total}: {formatTokens(total)}</div>
<div>
{t.analytics.input}: {formatTokens(d.input_tokens)}
</div>
<div>
{t.analytics.output}: {formatTokens(d.output_tokens)}
</div>
<div>
{t.analytics.total}: {formatTokens(total)}
</div>
</div>
</div>
{/* Input bar */}
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
{/* Output bar */}
<div
className="w-full bg-emerald-500/70"
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
style={{
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
</div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
<span>
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
</span>
</div>
</CardContent>
</Card>
@@ -148,7 +142,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
<CardTitle className="text-base">
{t.analytics.dailyBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -156,23 +152,42 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
<th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
<th className="text-left py-2 pr-4 font-medium">
{t.analytics.date}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.sessions.title}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.analytics.input}
</th>
<th className="text-right py-2 pl-4 font-medium">
{t.analytics.output}
</th>
</tr>
</thead>
<tbody>
{sorted.map((d) => {
return (
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
<tr
key={d.day}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4 font-medium">
{formatDate(d.day)}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
<span className="text-[#ffe6cb]">
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
<span className="text-emerald-400">
{formatTokens(d.output_tokens)}
</span>
</td>
</tr>
);
@@ -190,7 +205,8 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
if (models.length === 0) return null;
const sorted = [...models].sort(
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
(a, b) =>
b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
);
return (
@@ -198,7 +214,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
<CardTitle className="text-base">
{t.analytics.perModelBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -206,22 +224,37 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
<th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
<th className="text-left py-2 pr-4 font-medium">
{t.analytics.model}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.sessions.title}
</th>
<th className="text-right py-2 pl-4 font-medium">
{t.analytics.tokens}
</th>
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<tr
key={m.model}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
<span className="text-[#ffe6cb]">
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
<span className="text-emerald-400">
{formatTokens(m.output_tokens)}
</span>
</td>
</tr>
))}
@@ -250,21 +283,38 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">{t.analytics.skill}</th>
<th className="text-right py-2 px-4 font-medium">{t.analytics.loads}</th>
<th className="text-right py-2 px-4 font-medium">{t.analytics.edits}</th>
<th className="text-right py-2 px-4 font-medium">{t.analytics.total}</th>
<th className="text-right py-2 pl-4 font-medium">{t.analytics.lastUsed}</th>
<th className="text-left py-2 pr-4 font-medium">
{t.analytics.skill}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.analytics.loads}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.analytics.edits}
</th>
<th className="text-right py-2 px-4 font-medium">
{t.analytics.total}
</th>
<th className="text-right py-2 pl-4 font-medium">
{t.analytics.lastUsed}
</th>
</tr>
</thead>
<tbody>
{skills.map((skill) => (
<tr key={skill.skill} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<tr
key={skill.skill}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{skill.skill}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">{skill.view_count}</td>
<td className="text-right py-2 px-4 text-muted-foreground">{skill.manage_count}</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.view_count}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.manage_count}
</td>
<td className="text-right py-2 px-4">{skill.total_count}</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
@@ -302,10 +352,8 @@ export default function AnalyticsPage() {
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]">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{periodLabel}
</Badge>
</span>,
@@ -317,9 +365,8 @@ export default function AnalyticsPage() {
<Button
key={p.label}
type="button"
variant={days === p.days ? "default" : "outline"}
size="sm"
className="h-7 min-w-0 text-xs"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
@@ -328,13 +375,12 @@ export default function AnalyticsPage() {
</div>
<Button
type="button"
variant="outline"
size="sm"
outlined
onClick={load}
disabled={loading}
className="h-7 text-xs"
prefix={loading ? <Spinner /> : <RefreshCw />}
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
@@ -354,7 +400,7 @@ export default function AnalyticsPage() {
<PluginSlot name="analytics:top" />
{loading && !data && (
<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" />
<Spinner className="text-2xl text-primary" />
</div>
)}
@@ -368,49 +414,66 @@ export default function AnalyticsPage() {
{data && (
<>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard
icon={Hash}
label={t.analytics.totalTokens}
value={formatTokens(data.totals.total_input + data.totals.total_output)}
sub={t.analytics.inOut.replace("{input}", formatTokens(data.totals.total_input)).replace("{output}", formatTokens(data.totals.total_output))}
/>
<SummaryCard
icon={BarChart3}
label={t.analytics.totalSessions}
value={String(data.totals.total_sessions)}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg}`}
/>
<SummaryCard
icon={TrendingUp}
label={t.analytics.apiCalls}
value={String(data.totals.total_api_calls ?? data.daily.reduce((sum, d) => sum + d.sessions, 0))}
sub={t.analytics.acrossModels.replace("{count}", String(data.by_model.length))}
/>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="py-6">
<Stats
items={[
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.analytics.totalSessions,
value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
},
{
label: t.analytics.apiCalls,
value: String(
data.totals.total_api_calls ??
data.daily.reduce((sum, d) => sum + d.sessions, 0),
),
},
]}
/>
</CardContent>
</Card>
<TokenBarChart daily={data.daily} />
</div>
{/* Bar chart */}
<TokenBarChart daily={data.daily} />
{/* Tables */}
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
<SkillTable skills={data.skills.top_skills} />
</>
)}
{data && data.daily.length === 0 && data.by_model.length === 0 && data.skills.top_skills.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
</div>
</CardContent>
</Card>
)}
{data &&
data.daily.length === 0 &&
data.by_model.length === 0 &&
data.skills.top_skills.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">
{t.analytics.startSession}
</p>
</div>
</CardContent>
</Card>
)}
<PluginSlot name="analytics:bottom" />
</div>
);
+33 -36
View File
@@ -22,7 +22,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Typography } from "@nous-research/ui";
import { Button, Typography } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -192,22 +192,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
return;
}
setEnd(
<button
type="button"
<Button
ghost
onClick={() => setMobilePanelOpenRaw(true)}
className={cn(
"inline-flex items-center gap-1.5 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
"text-midground/80 hover:text-midground hover:bg-midground/5",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
"shrink-0 cursor-pointer",
)}
aria-expanded={mobilePanelOpen}
aria-controls="chat-side-panel"
className={cn(
"shrink-0 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
"text-midground/80 hover:text-midground hover:bg-midground/5",
)}
>
<PanelRight className="h-3 w-3 shrink-0" />
{modelToolsLabel}
</button>,
<span className="inline-flex items-center gap-1.5">
<PanelRight className="h-3 w-3 shrink-0" />
{modelToolsLabel}
</span>
</Button>,
);
return () => setEnd(null);
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
@@ -690,13 +690,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
createPortal(
<>
{mobilePanelOpen && (
<button
type="button"
<Button
ghost
aria-label={t.app.closeModelTools}
onClick={closeMobilePanel}
className={cn(
"fixed inset-0 z-[55]",
"bg-black/60 backdrop-blur-sm cursor-pointer",
"fixed inset-0 z-[55] p-0 block",
"bg-black/60 backdrop-blur-sm",
)}
/>
)}
@@ -732,18 +732,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
{t.app.modelToolsSheetSubtitle}
</Typography>
<button
type="button"
<Button
ghost
size="icon"
onClick={closeMobilePanel}
aria-label={t.app.closeModelTools}
className={cn(
"inline-flex h-7 w-7 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
className="text-midground/70 hover:text-midground"
>
<X className="h-4 w-4" />
</button>
<X />
</Button>
</div>
<div
@@ -786,29 +783,29 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
<button
type="button"
<Button
ghost
onClick={handleCopyLast}
title="Copy last assistant response as raw markdown"
aria-label="Copy last assistant response"
className={cn(
"absolute z-10 flex items-center gap-1.5",
"absolute z-10",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"opacity-60 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
"cursor-pointer",
"transition-opacity duration-150 normal-case font-normal tracking-normal",
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME.foreground }}
>
<Copy className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"}
<span className="inline-flex items-center gap-1.5">
<Copy className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"}
</span>
</span>
</button>
</Button>
</div>
{!narrow && (
+160 -88
View File
@@ -33,10 +33,10 @@ import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField";
import { Button, ListItem, Spinner } from "@nous-research/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Badge } from "@nous-research/ui";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
@@ -45,7 +45,10 @@ import { PluginSlot } from "@/plugins";
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
const CATEGORY_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
general: Settings,
agent: Bot,
terminal: Monitor,
@@ -63,7 +66,13 @@ const CATEGORY_ICONS: Record<string, React.ComponentType<{ className?: string }>
auxiliary: Wrench,
};
function CategoryIcon({ category, className }: { category: string; className?: string }) {
function CategoryIcon({
category,
className,
}: {
category: string;
className?: string;
}) {
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
return <Icon className={className ?? "h-4 w-4"} />;
}
@@ -74,9 +83,14 @@ function CategoryIcon({ category, className }: { category: string; className?: s
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
const [schema, setSchema] = useState<Record<
string,
Record<string, unknown>
> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
null,
);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
@@ -104,18 +118,20 @@ export default function ConfigPage() {
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"
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
aria-label={t.common.clear}
>
<X className="h-3 w-3" />
</button>
<X />
</Button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.search]);
}, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories;
@@ -124,7 +140,10 @@ export default function ConfigPage() {
}
useEffect(() => {
api.getConfig().then(setConfig).catch(() => {});
api
.getConfig()
.then(setConfig)
.catch(() => {});
api
.getSchema()
.then((resp) => {
@@ -132,7 +151,10 @@ export default function ConfigPage() {
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api.getDefaults().then(setDefaults).catch(() => {});
api
.getDefaults()
.then(setDefaults)
.catch(() => {});
}, []);
// Set active category when categories load
@@ -157,7 +179,11 @@ export default function ConfigPage() {
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
const allCats = [
...new Set(
Object.values(schema).map((s) => String(s.category ?? "general")),
),
];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
@@ -186,8 +212,12 @@ export default function ConfigPage() {
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
String(s.description ?? "").toLowerCase().includes(lowerSearch)
String(s.category ?? "")
.toLowerCase()
.includes(lowerSearch) ||
String(s.description ?? "")
.toLowerCase()
.includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
@@ -196,7 +226,7 @@ export default function ConfigPage() {
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory
([, s]) => String(s.category ?? "general") === activeCategory,
);
}, [schema, activeCategory, isSearching]);
@@ -219,7 +249,10 @@ export default function ConfigPage() {
try {
await api.saveConfigRaw(yamlText);
showToast(t.config.yamlConfigSaved, "success");
api.getConfig().then(setConfig).catch(() => {});
api
.getConfig()
.then(setConfig)
.catch(() => {});
} catch (e) {
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
} finally {
@@ -247,12 +280,17 @@ export default function ConfigPage() {
next = setNestedValue(next, key, getNestedValue(defaults, key));
}
setConfig(next);
showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success");
showToast(
t.config.resetScopeToast.replace("{scope}", scopeLabel),
"success",
);
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -281,13 +319,16 @@ export default function ConfigPage() {
if (!config || !schema) {
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" />
<Spinner className="text-2xl text-primary" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
const renderFields = (
fields: [string, Record<string, unknown>][],
showCategory = false,
) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
@@ -295,7 +336,11 @@ export default function ConfigPage() {
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
const showSection =
!showCategory &&
section &&
section !== lastSection &&
section !== activeCategory;
lastSection = section;
lastCat = cat;
@@ -303,7 +348,10 @@ export default function ConfigPage() {
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<CategoryIcon category={cat} className="h-4 w-4 text-muted-foreground" />
<CategoryIcon
category={cat}
className="h-4 w-4 text-muted-foreground"
/>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
@@ -336,7 +384,6 @@ export default function ConfigPage() {
<PluginSlot name="config:top" />
<Toast toast={toast} />
{/* ═══════════════ Header Bar ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -345,61 +392,86 @@ export default function ConfigPage() {
</code>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={handleExport} title={t.config.exportConfig} aria-label={t.config.exportConfig}>
<Download className="h-3.5 w-3.5" />
<Button
ghost
size="icon"
onClick={handleExport}
title={t.config.exportConfig}
aria-label={t.config.exportConfig}
>
<Download />
</Button>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}>
<Upload className="h-3.5 w-3.5" />
<Button
ghost
size="icon"
onClick={() => fileInputRef.current?.click()}
title={t.config.importConfig}
aria-label={t.config.importConfig}
>
<Upload />
</Button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
{!yamlMode && (() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel);
return (
<Button variant="ghost" size="sm" onClick={handleReset} title={resetTitle} aria-label={resetTitle}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
);
})()}
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
{!yamlMode &&
(() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace(
"{scope}",
resetScopeLabel,
);
return (
<Button
ghost
size="icon"
onClick={handleReset}
title={resetTitle}
aria-label={resetTitle}
>
<RotateCcw />
</Button>
);
})()}
<div className="w-px h-5 bg-border mx-1" />
<Button
variant={yamlMode ? "default" : "outline"}
size="sm"
outlined={!yamlMode}
onClick={() => setYamlMode(!yamlMode)}
className="gap-1.5"
prefix={yamlMode ? <FormInput /> : <Code />}
>
{yamlMode ? (
<>
<FormInput className="h-3.5 w-3.5" />
{t.common.form}
</>
) : (
<>
<Code className="h-3.5 w-3.5" />
YAML
</>
)}
{yamlMode ? t.common.form : "YAML"}
</Button>
{yamlMode ? (
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
<Button
size="sm"
onClick={handleYamlSave}
disabled={yamlSaving}
prefix={<Save />}
>
{yamlSaving ? t.common.saving : t.common.save}
</Button>
) : (
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
<Button
size="sm"
onClick={handleSave}
disabled={saving}
prefix={<Save />}
>
{saving ? t.common.saving : t.common.save}
</Button>
)}
</div>
</div>
{/* ═══════════════ YAML Mode ═══════════════ */}
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
@@ -411,7 +483,7 @@ export default function ConfigPage() {
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
@@ -424,13 +496,10 @@ export default function ConfigPage() {
</CardContent>
</Card>
) : (
/* ═══════════════ Form Mode ═══════════════ */
<div className="flex flex-col sm:flex-row gap-4">
{/* ---- Filter panel ---- */}
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
{/* Panel 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">
@@ -438,37 +507,31 @@ export default function ConfigPage() {
</span>
</div>
{/* Sections heading (hidden on mobile since it becomes a horizontal scroll) */}
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.config.sections}
</div>
{/* Category nav — horizontal scroll on mobile, pill list on sm+ */}
<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">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<button
<ListItem
key={cat}
type="button"
active={isActive}
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`
group flex items-center gap-2 px-2 py-1
rounded-sm text-left text-[11px] cursor-pointer whitespace-nowrap
transition-colors
${
isActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
}
`}
className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]"
>
<CategoryIcon category={cat} className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<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
@@ -478,7 +541,7 @@ export default function ConfigPage() {
>
{categoryCounts[cat] || 0}
</span>
</button>
</ListItem>
);
})}
</div>
@@ -486,10 +549,8 @@ export default function ConfigPage() {
</div>
</aside>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{isSearching ? (
/* Search results */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
@@ -497,8 +558,12 @@ export default function ConfigPage() {
<Search className="h-4 w-4" />
{t.config.searchResults}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
<Badge tone="secondary" className="text-[10px]">
{searchMatchedFields.length}{" "}
{t.config.fields.replace(
"{s}",
searchMatchedFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
@@ -518,11 +583,18 @@ export default function ConfigPage() {
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<CategoryIcon category={activeCategory} className="h-4 w-4" />
<CategoryIcon
category={activeCategory}
className="h-4 w-4"
/>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
<Badge tone="secondary" className="text-[10px]">
{activeFields.length}{" "}
{t.config.fields.replace(
"{s}",
activeFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
+17 -23
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { H2 } from "@nous-research/ui";
import { Badge, Button, H2, Select, SelectOption, Spinner } from "@nous-research/ui";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
@@ -8,11 +8,8 @@ import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectOption } from "@/components/ui/select";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@@ -22,7 +19,7 @@ function formatTime(iso?: string | null): string {
return d.toLocaleString();
}
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
paused: "warning",
@@ -139,7 +136,7 @@ export default function CronPage() {
if (loading) {
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" />
<Spinner className="text-2xl text-primary" />
</div>
);
}
@@ -166,7 +163,6 @@ export default function CronPage() {
loading={jobDelete.isDeleting}
/>
{/* Create new job form */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -237,9 +233,9 @@ export default function CronPage() {
<Button
onClick={handleCreate}
disabled={creating}
prefix={<Plus />}
className="w-full"
>
<Plus className="h-3 w-3" />
{creating ? t.common.creating : t.common.create}
</Button>
</div>
@@ -248,7 +244,6 @@ export default function CronPage() {
</CardContent>
</Card>
{/* Jobs list */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
@@ -269,7 +264,6 @@ export default function CronPage() {
{jobs.map((job) => (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
@@ -277,11 +271,11 @@ export default function CronPage() {
job.prompt.slice(0, 60) +
(job.prompt.length > 60 ? "..." : "")}
</span>
<Badge variant={STATUS_VARIANT[job.state] ?? "secondary"}>
<Badge tone={STATUS_TONE[job.state] ?? "secondary"}>
{job.state}
</Badge>
{job.deliver && job.deliver !== "local" && (
<Badge variant="outline">{job.deliver}</Badge>
<Badge tone="outline">{job.deliver}</Badge>
)}
</div>
{job.name && (
@@ -306,48 +300,48 @@ export default function CronPage() {
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
ghost
size="icon"
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
job.state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
job.state === "paused" ? "text-success" : "text-warning"
}
>
{job.state === "paused" ? (
<Play className="h-4 w-4 text-success" />
) : (
<Pause className="h-4 w-4 text-warning" />
)}
{job.state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
variant="ghost"
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap className="h-4 w-4" />
<Zap />
</Button>
<Button
variant="ghost"
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<PluginSlot name="cron:bottom" />
</div>
);
+10 -6
View File
@@ -2,12 +2,19 @@ import { useLayoutEffect } from "react";
import { ExternalLink } from "lucide-react";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { PluginSlot } from "@/plugins";
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
const DS_BUTTON_OUTLINED_LINK_CN = cn(
"group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
"px-[.9em_.75em] py-[1.25em] gap-2",
"leading-0 font-bold tracking-[0.2em] uppercase",
"text-midground bg-transparent shadow-midground",
"shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
);
export default function DocsPage() {
const { t } = useI18n();
const { setEnd } = usePageHeader();
@@ -18,12 +25,9 @@ export default function DocsPage() {
href={HERMES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"h-7 text-xs",
)}
className={DS_BUTTON_OUTLINED_LINK_CN}
>
<ExternalLink className="mr-1.5 h-3 w-3" />
<ExternalLink className="size-3.5" />
{t.app.openDocumentation}
</a>,
);
+357 -162
View File
@@ -21,9 +21,15 @@ import { Toast } from "@/components/Toast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { useToast } from "@/hooks/useToast";
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, ListItem, Spinner } from "@nous-research/ui";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@nous-research/ui";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
@@ -36,25 +42,25 @@ import { PluginSlot } from "@/plugins";
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
// Nous Portal first
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
// Then alphabetical by display name
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
];
function getProviderGroup(key: string): string {
@@ -117,26 +123,39 @@ function EnvVarRow({
const { t } = useI18n();
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
const displayValue = isRevealed
? revealed[varKey]
: (info.redacted_value ?? "---");
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
{varKey}
</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-2.5 w-2.5" />
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
@@ -149,19 +168,30 @@ function EnvVarRow({
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
{varKey}
</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
@@ -175,13 +205,17 @@ function EnvVarRow({
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge variant={info.is_set ? "success" : "outline"}>
<Badge tone={info.is_set ? "success" : "outline"}>
{info.is_set ? t.common.set : t.env.notSet}
</Badge>
</div>
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
@@ -192,40 +226,59 @@ function EnvVarRow({
{info.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{info.tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
<Badge
key={tool}
tone="secondary"
className="text-[0.6rem] py-0 px-1.5"
>
{tool}
</Badge>
))}
</div>
)}
{!isEditing && (
<div className="flex items-center gap-2">
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
}`}>
<div
className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed
? "bg-background text-foreground select-all"
: "bg-muted/30 text-muted-foreground"
}`}
>
{info.is_set ? displayValue : "---"}
</div>
{info.is_set && (
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
<Button
ghost
size="icon"
onClick={() => onReveal(varKey)}
title={isRevealed ? t.env.hideValue : t.env.showValue}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
{isRevealed
? <EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button size="sm" variant="outline"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{info.is_set ? t.common.replace : t.common.set}
</Button>
{info.is_set && (
<Button size="sm" variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey || clearDialogOpen}>
<Trash2 className="h-3 w-3" />
<Button
size="sm"
outlined
destructive
prefix={<Trash2 />}
onClick={() => onClear(varKey)}
disabled={saving === varKey || clearDialogOpen}
>
{saving === varKey ? "..." : t.common.clear}
</Button>
)}
@@ -234,17 +287,38 @@ function EnvVarRow({
{isEditing && (
<div className="flex items-center gap-2">
<Input autoFocus type="text" value={edits[varKey]}
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue}
className="flex-1 font-mono-ui text-xs" />
<Button size="sm" onClick={() => onSave(varKey)}
disabled={saving === varKey || !edits[varKey]}>
<Save className="h-3 w-3" />
<Input
autoFocus
type="text"
value={edits[varKey]}
onChange={(e) =>
setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
}
placeholder={
info.is_set
? t.env.replaceCurrentValue.replace(
"{preview}",
info.redacted_value ?? "---",
)
: t.env.enterValue
}
className="flex-1 font-mono-ui text-xs"
/>
<Button
size="sm"
onClick={() => onSave(varKey)}
prefix={<Save />}
disabled={saving === varKey || !edits[varKey]}
>
{saving === varKey ? "..." : t.common.save}
</Button>
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
<X className="h-3 w-3" /> {t.common.cancel}
<Button
size="sm"
outlined
prefix={<X />}
onClick={() => onCancelEdit(varKey)}
>
{t.common.cancel}
</Button>
</div>
)}
@@ -283,11 +357,20 @@ function ProviderGroupCard({
const { t } = useI18n();
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
const apiKeys = group.entries.filter(
([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
);
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
const other = group.entries.filter(
([k]) =>
!k.endsWith("_API_KEY") &&
!k.endsWith("_TOKEN") &&
!k.endsWith("_BASE_URL"),
);
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
const configuredCount = group.entries.filter(
([, info]) => info.is_set,
).length;
// Get a representative URL for "Get key" link
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
@@ -295,61 +378,98 @@ function ProviderGroupCard({
return (
<div className="border border-border">
{/* Header — always visible */}
<button
type="button"
<ListItem
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
aria-expanded={expanded}
className="justify-between gap-3 px-4 py-3 hover:bg-primary/5"
>
<div className="flex items-center gap-3 min-w-0">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm tracking-wide">{group.name === "Other" ? t.common.other : group.name}</span>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="font-semibold text-sm tracking-wide">
{group.name === "Other" ? t.common.other : group.name}
</span>
{hasAnyConfigured && (
<Badge variant="success" className="text-[0.6rem]">
<Badge tone="success" className="text-[0.6rem]">
{configuredCount} {t.common.set.toLowerCase()}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{keyUrl && (
<a href={keyUrl} target="_blank" rel="noreferrer"
<a
href={keyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}>
onClick={(e) => e.stopPropagation()}
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
{t.env.keysCount.replace("{count}", String(group.entries.length)).replace("{s}", group.entries.length !== 1 ? "s" : "")}
{t.env.keysCount
.replace("{count}", String(group.entries.length))
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
</span>
</div>
</button>
</ListItem>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border px-4 py-3 grid gap-2">
{/* API keys first (most important) */}
{apiKeys.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{/* Base URLs (secondary) */}
{baseUrls.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{/* Anything else */}
{other.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
@@ -373,7 +493,10 @@ export default function EnvPage() {
const { t } = useI18n();
useEffect(() => {
api.getEnvVars().then(setVars).catch(() => {});
api
.getEnvVars()
.then(setVars)
.catch(() => {});
}, []);
const handleSave = async (key: string) => {
@@ -386,12 +509,24 @@ export default function EnvPage() {
prev
? {
...prev,
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
[key]: {
...prev[key],
is_set: true,
redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
},
}
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
} catch (e) {
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
@@ -408,11 +543,22 @@ export default function EnvPage() {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
? {
...prev,
[key]: { ...prev[key], is_set: false, redacted_value: null },
}
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
@@ -427,7 +573,11 @@ export default function EnvPage() {
const handleReveal = async (key: string) => {
if (revealed[key]) {
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
return;
}
try {
@@ -439,7 +589,11 @@ export default function EnvPage() {
};
const cancelEdit = (key: string) => {
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
};
/* ---- Build provider groups ---- */
@@ -447,7 +601,8 @@ export default function EnvPage() {
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
const providerEntries = Object.entries(vars).filter(
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
([, info]) =>
info.category === "provider" && (showAdvanced || !info.advanced),
);
// Group by provider
@@ -496,7 +651,7 @@ export default function EnvPage() {
if (!vars) {
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" />
<Spinner className="text-2xl text-primary" />
</div>
);
}
@@ -506,9 +661,7 @@ export default function EnvPage() {
const pendingClearKey = keyClear.pendingId;
const pendingKeyDescription =
pendingClearKey && vars
? vars[pendingClearKey]?.description
: undefined;
pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
return (
<div className="flex flex-col gap-6">
@@ -537,18 +690,20 @@ export default function EnvPage() {
{t.env.changesNote}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
<Button
size="sm"
outlined
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
</Button>
</div>
{/* ═══════════════ OAuth Logins ══ */}
<OAuthProvidersCard
onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")}
/>
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
<Card>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
@@ -556,7 +711,9 @@ export default function EnvPage() {
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
</div>
<CardDescription>
{t.env.providersConfigured.replace("{configured}", String(configuredProviders)).replace("{total}", String(totalProviders))}
{t.env.providersConfigured
.replace("{configured}", String(configuredProviders))
.replace("{total}", String(totalProviders))}
</CardDescription>
</CardHeader>
@@ -565,53 +722,82 @@ export default function EnvPage() {
<ProviderGroupCard
key={group.name}
group={group}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
</CardContent>
</Card>
{/* ═══════════════ Other categories (flat) ═══════════════ */}
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
if (totalEntries === 0) return null;
{nonProviderGrouped.map(
({
label,
icon: Icon,
setEntries,
unsetEntries,
totalEntries,
category,
}) => {
if (totalEntries === 0) return null;
return (
<Card key={category}>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} {t.common.of} {totalEntries} {t.common.configured}
</CardDescription>
</CardHeader>
return (
<Card key={category}>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} {t.common.of} {totalEntries}{" "}
{t.common.configured}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 pt-4">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
<CardContent className="grid gap-3 pt-4">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={keyClear.requestDelete} onReveal={handleReveal} onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
)}
</CardContent>
</Card>
);
})}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
)}
</CardContent>
</Card>
);
},
)}
<PluginSlot name="env:bottom" />
</div>
);
@@ -651,25 +837,34 @@ function CollapsibleUnset({
return (
<>
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
<Button
ghost
size="sm"
prefix={collapsed ? <ChevronRight /> : <ChevronDown />}
onClick={() => setCollapsed(!collapsed)}
aria-expanded={!collapsed}
className="self-start mt-1 normal-case tracking-normal text-xs text-muted-foreground hover:text-foreground"
>
{collapsed
? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />}
<span>{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}</span>
</button>
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}
</Button>
{!collapsed && unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{!collapsed &&
unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</>
);
}
+30 -17
View File
@@ -1,12 +1,22 @@
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from "react";
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import {
Badge,
Button,
FilterGroup,
Segmented,
Spinner,
Switch,
} from "@nous-research/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { FilterGroup, Segmented } from "@/components/ui/segmented";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
@@ -73,10 +83,8 @@ export default function LogsPage() {
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]">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</span>,
@@ -93,7 +101,7 @@ export default function LogsPage() {
{t.logs.autoRefresh}
</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<Badge tone="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>
@@ -101,13 +109,12 @@ export default function LogsPage() {
</div>
<Button
type="button"
variant="outline"
size="sm"
outlined
onClick={fetchLogs}
disabled={loading}
className="h-7 text-xs"
prefix={loading ? <Spinner /> : <RefreshCw />}
>
<RefreshCw className="mr-1 h-3 w-3" />
{t.common.refresh}
</Button>
</div>,
@@ -143,18 +150,25 @@ export default function LogsPage() {
return (
<div className="flex flex-col gap-4">
<PluginSlot name="logs:top" />
{/* ═══════════════ Filter toolbar ═══════════════ */}
<div
role="toolbar"
aria-label={t.logs.title}
className="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<FilterGroup label={t.logs.file}>
<Segmented value={file} onChange={setFile} options={toOptions(FILES)} />
<Segmented
value={file}
onChange={setFile}
options={toOptions(FILES)}
/>
</FilterGroup>
<FilterGroup label={t.logs.level}>
<Segmented value={level} onChange={setLevel} options={toOptions(LEVELS)} />
<Segmented
value={level}
onChange={setLevel}
options={toOptions(LEVELS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.component}>
@@ -179,7 +193,6 @@ export default function LogsPage() {
</FilterGroup>
</div>
{/* ═══════════════ Log viewer ═══════════════ */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
+53 -45
View File
@@ -13,7 +13,6 @@ import {
ChevronLeft,
ChevronRight,
Database,
Loader2,
MessageSquare,
Search,
Trash2,
@@ -36,8 +35,8 @@ import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, ListItem, Spinner } from "@nous-research/ui";
import { Badge } from "@nous-research/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
@@ -105,11 +104,11 @@ function ToolCallBlock({
return (
<div className="mt-2 border border-warning/20 bg-warning/5">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
<ListItem
onClick={() => setOpen(!open)}
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
aria-expanded={open}
className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning"
>
{open ? (
<ChevronDown className="h-3 w-3" />
@@ -120,7 +119,7 @@ function ToolCallBlock({
{toolCall.function.name}
</span>
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
</button>
</ListItem>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
@@ -190,7 +189,7 @@ function MessageBubble({
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge variant="warning" className="text-[9px] py-0 px-1.5">
<Badge tone="warning" className="text-[9px] py-0 px-1.5">
{t.common.match}
</Badge>
)}
@@ -321,7 +320,7 @@ function SessionRow({
: t.sessions.untitledSession}
</span>
{session.is_active && (
<Badge variant="success" className="text-[10px] shrink-0">
<Badge tone="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>
@@ -351,14 +350,14 @@ function SessionRow({
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px]">
<Badge tone="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
variant="ghost"
ghost
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-success"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
@@ -366,20 +365,20 @@ function SessionRow({
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play className="h-3.5 w-3.5" />
<Play />
</Button>
)}
<Button
variant="ghost"
ghost
destructive
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 />
</Button>
</div>
</div>
@@ -388,7 +387,7 @@ function SessionRow({
<div className="border-t border-border bg-background/50 p-4">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<Spinner className="text-xl text-primary" />
</div>
)}
{error && (
@@ -437,14 +436,14 @@ export default function SessionsPage() {
return;
}
setAfterTitle(
<Badge variant="secondary" className="text-xs tabular-nums">
<Badge tone="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" />
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
@@ -455,13 +454,15 @@ export default function SessionsPage() {
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"
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X className="h-3 w-3" />
</button>
<X />
</Button>
)}
</div>,
);
@@ -475,6 +476,7 @@ export default function SessionsPage() {
searching,
setAfterTitle,
setEnd,
t.common.clear,
t.sessions.searchPlaceholder,
total,
]);
@@ -497,7 +499,10 @@ export default function SessionsPage() {
useEffect(() => {
const loadOverview = () => {
api.getStatus().then(setStatus).catch(() => {});
api
.getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((r) => setOverviewSessions(r.sessions))
@@ -551,7 +556,12 @@ export default function SessionsPage() {
throw new Error("delete failed");
}
},
[expandedId, showToast, t.sessions.sessionDeleted, t.sessions.failedToDelete],
[
expandedId,
showToast,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
@@ -606,7 +616,7 @@ export default function SessionsPage() {
if (loading) {
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" />
<Spinner className="text-2xl text-primary" />
</div>
);
}
@@ -656,13 +666,13 @@ export default function SessionsPage() {
<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" />
<Spinner className="shrink-0 text-[0.875rem] 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" />
<Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
@@ -672,7 +682,7 @@ export default function SessionsPage() {
</span>
<Badge
variant={
tone={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
@@ -693,14 +703,15 @@ export default function SessionsPage() {
</Badge>
</div>
<button
type="button"
<Button
ghost
size="icon"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
className="shrink-0 opacity-60 hover:opacity-100"
aria-label={t.common.close}
>
<X className="h-3.5 w-3.5" />
</button>
<X />
</Button>
</div>
<pre
@@ -756,7 +767,7 @@ export default function SessionsPage() {
</div>
<Badge
variant="outline"
tone="outline"
className="text-[10px] shrink-0 self-start sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
@@ -799,7 +810,6 @@ export default function SessionsPage() {
))}
</div>
{/* Pagination — hidden during search */}
{!searchResults && total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
@@ -808,28 +818,26 @@ export default function SessionsPage() {
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
outlined
size="icon"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft />
</Button>
<span className="text-xs text-muted-foreground px-2">
{t.common.page} {page + 1} {t.common.of}{" "}
{Math.ceil(total / PAGE_SIZE)}
</span>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight className="h-4 w-4" />
<ChevronRight />
</Button>
</div>
</div>
+56 -85
View File
@@ -20,9 +20,9 @@ import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Badge, Button, ListItem, Spinner, Switch } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
@@ -207,13 +207,15 @@ export default function SkillsPage() {
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"
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X className="h-3 w-3" />
</button>
<X />
</Button>
)}
</div>,
);
@@ -221,15 +223,7 @@ export default function SkillsPage() {
setAfterTitle(null);
setEnd(null);
};
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
]);
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
@@ -245,7 +239,7 @@ export default function SkillsPage() {
if (loading) {
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" />
<Spinner className="text-2xl text-primary" />
</div>
);
}
@@ -255,13 +249,8 @@ export default function SkillsPage() {
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{/* ═══════════════ Filter panel + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
{/* ---- Filter panel ---- */}
<aside
aria-label={t.skills.title}
className="sm:w-56 sm:shrink-0"
>
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">
<div
className={`
@@ -269,7 +258,6 @@ export default function SkillsPage() {
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">
@@ -277,7 +265,6 @@ export default function SkillsPage() {
</span>
</div>
{/* View switch (Skills / Toolsets) */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
<PanelItem
icon={Package}
@@ -300,58 +287,48 @@ export default function SkillsPage() {
/>
</div>
{/* Category sub-filters (only for Skills view) */}
{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;
{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"
return (
<ListItem
key={key}
active={isActive}
onClick={() =>
setActiveCategory(isActive ? null : key)
}
`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
className="rounded-sm px-2 py-1 text-[11px]"
>
{count}
</span>
</button>
);
})}
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{count}
</span>
</ListItem>
);
})}
</div>
</div>
</div>
)}
)}
</div>
</div>
</aside>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{isSearching ? (
/* Search results */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
@@ -359,7 +336,7 @@ export default function SkillsPage() {
<Search className="h-4 w-4" />
{t.skills.title}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-[10px]">
{t.skills.resultCount
.replace("{count}", String(searchMatchedSkills.length))
.replace(
@@ -403,7 +380,7 @@ export default function SkillsPage() {
)
: t.skills.all}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
<Badge tone="secondary" className="text-[10px]">
{t.skills.skillCount
.replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
@@ -460,7 +437,7 @@ export default function SkillsPage() {
{labelText}
</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
tone={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled
@@ -481,7 +458,7 @@ export default function SkillsPage() {
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
tone="secondary"
className="text-[10px] font-mono"
>
{tool}
@@ -551,24 +528,18 @@ function SkillRow({
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<button
type="button"
<ListItem
active={active}
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"
}
`}
className={cn(
"rounded-sm whitespace-nowrap px-2.5 py-1.5",
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
active && "bg-foreground/90 text-background hover:text-background",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</button>
</ListItem>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react";
import { Loader2 } from "lucide-react";
import { Spinner } from "@nous-research/ui";
import {
getPluginComponent,
getPluginLoadError,
@@ -51,7 +51,7 @@ export function PluginPage({ name }: { name: string }) {
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
)}
>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
<Spinner className="shrink-0" />
<span>{t.common.loading}</span>
</div>
);
+2 -4
View File
@@ -19,14 +19,12 @@ import React, {
} from "react";
import { api, fetchJSON } from "@/lib/api";
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
import { Badge, Button, Select, SelectOption } from "@nous-research/ui";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectOption } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui";
import { useI18n } from "@/i18n";
import { registerSlot, PluginSlot } from "./slots";
+18
View File
@@ -70,6 +70,24 @@ export default defineConfig({
alias: {
"@": path.resolve(__dirname, "./src"),
},
// When @nous-research/ui is symlinked via `file:../../design-language`,
// Node's module resolution would pick up shared deps from
// design-language/node_modules/*, giving us two copies + breaking
// hooks (useRef-of-null), webgl contexts, etc. Force everything that
// exists in BOTH places to use the dashboard's copy.
//
// Don't list packages here that only exist in the DS (nanostores,
// @nanostores/react) — Vite dedupe errors out when it can't find
// them at the project root.
dedupe: [
"react",
"react-dom",
"@react-three/fiber",
"@observablehq/plot",
"three",
"leva",
"gsap",
],
},
build: {
outDir: "../hermes_cli/web_dist",