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
commit 7d4648461a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1271 additions and 1429 deletions

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"; };

167
web/package-lock.json generated
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",

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",

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>
);
})}

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 : "";

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"

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>
)}

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>
);
}

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 && (

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>
);
})
)}

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(() => {});

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>
)}

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}

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>

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>

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>

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">

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} />;
}

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} />;
}

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}
>

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;
}

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;
}

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>
);
}

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}
/>
);
}

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>
);

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 && (

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>

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>
);

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>,
);

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}
/>
))}
</>
);
}

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">

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>

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>
);
}

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>
);

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";

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",