refactor(web): dashboard typography & contrast pass

Removes the global `uppercase` + `font-mondwest` from the App.tsx root
that forced every page to opt-out, replaces stacked-alpha text colors
with semantic tokens for WCAG-AA contrast across all 7 themes, and
applies the new `text-display` utility from @nous-research/ui@0.16.0
on intentional brand chrome (page titles, sidebar headings, segmented
filters) only. Bumps every sub-12px arbitrary text size to text-xs.

Also widens the dashboard plugin routes (/api/dashboard/agent-plugins/
{name:path}/...) so category-namespaced plugins like observability/
langfuse and image_gen/openai can be enable/disabled from the dashboard
— previously the FE encodeURIComponent-ed the slash and the backend
{name} route rejected it. _validate_plugin_name still blocks .. and
backslash, and strips leading/trailing slash.

Touches sessions/env/keys page chrome and adds two new i18n keys
(`overview`, `showMore`/`showLess`) across all 18 locales.

Squashes 19 commits from PR #28832.

Co-authored-by: Hermes <noreply@nousresearch.com>
This commit is contained in:
Austin Pickett
2026-05-22 19:46:55 -07:00
committed by Teknium
parent dc4b0465b5
commit 487c398dcf
54 changed files with 988 additions and 735 deletions

View File

@ -4319,12 +4319,13 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB
def _validate_plugin_name(name: str) -> str: def _validate_plugin_name(name: str) -> str:
"""Reject path-traversal attempts in plugin name URL parameters.""" """Reject path-traversal attempts in plugin name URL parameters."""
if not name or "/" in name or "\\" in name or ".." in name: name = name.strip("/")
if not name or ".." in name or "\\" in name:
raise HTTPException(status_code=400, detail="Invalid plugin name.") raise HTTPException(status_code=400, detail="Invalid plugin name.")
return name return name
@app.post("/api/dashboard/agent-plugins/{name}/enable") @app.post("/api/dashboard/agent-plugins/{name:path}/enable")
async def post_agent_plugin_enable(request: Request, name: str): async def post_agent_plugin_enable(request: Request, name: str):
_require_token(request) _require_token(request)
name = _validate_plugin_name(name) name = _validate_plugin_name(name)
@ -4336,7 +4337,7 @@ async def post_agent_plugin_enable(request: Request, name: str):
return result return result
@app.post("/api/dashboard/agent-plugins/{name}/disable") @app.post("/api/dashboard/agent-plugins/{name:path}/disable")
async def post_agent_plugin_disable(request: Request, name: str): async def post_agent_plugin_disable(request: Request, name: str):
_require_token(request) _require_token(request)
name = _validate_plugin_name(name) name = _validate_plugin_name(name)
@ -4348,7 +4349,7 @@ async def post_agent_plugin_disable(request: Request, name: str):
return result return result
@app.post("/api/dashboard/agent-plugins/{name}/update") @app.post("/api/dashboard/agent-plugins/{name:path}/update")
async def post_agent_plugin_update(request: Request, name: str): async def post_agent_plugin_update(request: Request, name: str):
_require_token(request) _require_token(request)
name = _validate_plugin_name(name) name = _validate_plugin_name(name)
@ -4361,7 +4362,7 @@ async def post_agent_plugin_update(request: Request, name: str):
return result return result
@app.delete("/api/dashboard/agent-plugins/{name}") @app.delete("/api/dashboard/agent-plugins/{name:path}")
async def delete_agent_plugin(request: Request, name: str): async def delete_agent_plugin(request: Request, name: str):
_require_token(request) _require_token(request)
name = _validate_plugin_name(name) name = _validate_plugin_name(name)
@ -4399,7 +4400,7 @@ class _PluginVisibilityBody(BaseModel):
hidden: bool hidden: bool
@app.post("/api/dashboard/plugins/{name}/visibility") @app.post("/api/dashboard/plugins/{name:path}/visibility")
async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody):
"""Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins)."""
_require_token(request) _require_token(request)

View File

@ -4,7 +4,7 @@ let
src = ../web; src = ../web;
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
inherit src; inherit src;
hash = "sha256-xSsyluzU2lNhwGqB6XMCGMv3QFHZizE6hgUyc1jvyOw="; hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
}; };
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View File

@ -17,9 +17,14 @@ python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy) # In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/ cd web/
npm install
npm run dev npm run dev
``` ```
Open the **Vite URL** printed in the terminal (usually `http://localhost:5173`). That is the live-reload UI.
`hermes dashboard` on port 9119 serves the **built** bundle from `hermes_cli/web_dist/`, not the Vite dev server — changes in `web/src/` will not appear there until you run `npm run build` and restart the dashboard (or use `web --no-open` + Vite as above).
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend). The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build ## Build
@ -46,3 +51,54 @@ src/
├── main.tsx # React entry point ├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables └── index.css # Tailwind imports and theme variables
``` ```
## Typography & contrast rules
Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of.
### Text size floor
- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`.
- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read.
### Opacity floor on text
- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on `<span>`s, `<p>`s, labels, etc.
- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha.
- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`:
- `text-text-primary` — default body text.
- `text-text-secondary` — subtitles, meta, inactive nav.
- `text-text-tertiary` — small chrome labels, counts, footnotes.
- `text-text-disabled` — disabled states.
- `text-text-on-accent` — text on filled accent surfaces.
### Brand uppercase via `text-display`, not raw `uppercase`
- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**.
- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`.
- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated.
- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”).
### Fonts
Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set.
| Tier | Classes | Use for |
|------|---------|---------|
| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings |
| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** |
| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` |
| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` |
| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs |
- Do **not** put `themedBody` or `themedFont` on `<main>`, `App`, or other layout wrappers — it overrides component-scoped styles.
- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`.
- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed.
- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate.
### Color tokens
- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`).
- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name.
- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text.

67
web/package-lock.json generated
View File

@ -8,7 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@nous-research/ui": "^0.14.2", "@nous-research/ui": "0.16.0",
"@observablehq/plot": "^0.6.17", "@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0", "@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@ -77,6 +77,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1079,9 +1080,9 @@
} }
}, },
"node_modules/@nous-research/ui": { "node_modules/@nous-research/ui": {
"version": "0.14.2", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.2.tgz", "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.16.0.tgz",
"integrity": "sha512-H3cMt2e0IpmcTNOmR6zVX+8ja48w4X4F/IFXhWCpaoVs8zKVRN12Ryb4RnX/ac8IrbUu6UsIds7ZtmXxPHcfdQ==", "integrity": "sha512-JvSwf9vBOCEEGDSOYIRn/F/JJSBDh9DvGU3s3OFbX6K1otnSK7s47cZdgvfBoEPmeKFom2fWQDDqfzLV+eR7Qg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nanostores/react": "^1.1.0", "@nanostores/react": "^1.1.0",
@ -1127,6 +1128,7 @@
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"d3": "^7.9.0", "d3": "^7.9.0",
"interval-tree-1d": "^1.0.0", "interval-tree-1d": "^1.0.0",
@ -1865,6 +1867,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/webxr": "*", "@types/webxr": "*",
@ -2570,6 +2573,7 @@
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -2579,6 +2583,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -2589,6 +2594,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -2653,6 +2659,7 @@
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.1",
@ -2981,6 +2988,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3133,6 +3141,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -3640,6 +3649,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -3959,6 +3969,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -4269,13 +4280,13 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.39.0", "version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==", "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^12.39.0", "motion-dom": "^12.38.0",
"motion-utils": "^12.39.0", "motion-utils": "^12.36.0",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -4364,7 +4375,8 @@
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license." "license": "Standard 'no charge' license: https://gsap.com/standard-license.",
"peer": true
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
@ -4679,6 +4691,7 @@
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
@ -5082,12 +5095,13 @@
} }
}, },
"node_modules/motion": { "node_modules/motion": {
"version": "12.39.0", "version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.39.0.tgz", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==", "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"framer-motion": "^12.39.0", "framer-motion": "^12.38.0",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -5108,18 +5122,18 @@
} }
}, },
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.39.0", "version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.39.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==", "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-utils": "^12.39.0" "motion-utils": "^12.36.0"
} }
}, },
"node_modules/motion-utils": { "node_modules/motion-utils": {
"version": "12.39.0", "version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ms": { "node_modules/ms": {
@ -5158,6 +5172,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^20.0.0 || >=22.0.0" "node": "^20.0.0 || >=22.0.0"
} }
@ -5285,6 +5300,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5356,6 +5372,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5375,6 +5392,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -5735,7 +5753,8 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
@ -5800,6 +5819,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5898,6 +5918,7 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
@ -5913,6 +5934,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -6034,6 +6056,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

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

View File

@ -326,7 +326,9 @@ export default function App() {
api api
.getConfig() .getConfig()
.then((cfg) => { .then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; const dash = (cfg?.dashboard ?? {}) as {
show_token_analytics?: unknown;
};
setShowTokenAnalytics(dash.show_token_analytics === true); setShowTokenAnalytics(dash.show_token_analytics === true);
}) })
.catch(() => setShowTokenAnalytics(false)); .catch(() => setShowTokenAnalytics(false));
@ -366,7 +368,9 @@ export default function App() {
const base = embeddedChat const base = embeddedChat
? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST]
: BUILTIN_NAV_REST; : BUILTIN_NAV_REST;
return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics"); return showTokenAnalytics
? base
: base.filter((n) => n.path !== "/analytics");
}, [embeddedChat, showTokenAnalytics]); }, [embeddedChat, showTokenAnalytics]);
const sidebarNav = useMemo( const sidebarNav = useMemo(
@ -416,7 +420,7 @@ export default function App() {
return ( return (
<div <div
data-layout-variant={layoutVariant} data-layout-variant={layoutVariant}
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased" className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
> >
<SelectionSwitcher /> <SelectionSwitcher />
<Backdrop /> <Backdrop />
@ -442,7 +446,7 @@ export default function App() {
aria-label={t.app.openNavigation} aria-label={t.app.openNavigation}
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
aria-controls="app-sidebar" aria-controls="app-sidebar"
className="text-midground/70 hover:text-midground" className="text-text-secondary hover:text-midground"
> >
<Menu /> <Menu />
</Button> </Button>
@ -498,7 +502,7 @@ export default function App() {
<PluginSlot name="header-left" /> <PluginSlot name="header-left" />
<Typography <Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground" className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
style={{ mixBlendMode: "plus-lighter" }} style={{ mixBlendMode: "plus-lighter" }}
> >
Hermes Hermes
@ -512,7 +516,7 @@ export default function App() {
size="icon" size="icon"
onClick={closeMobile} onClick={closeMobile}
aria-label={t.app.closeNavigation} aria-label={t.app.closeNavigation}
className="lg:hidden text-midground/70 hover:text-midground" className="lg:hidden text-text-secondary hover:text-midground"
> >
<X /> <X />
</Button> </Button>
@ -542,7 +546,7 @@ export default function App() {
<span <span
className={cn( className={cn(
"px-5 pt-2.5 pb-1", "px-5 pt-2.5 pb-1",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30", "font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
)} )}
id="hermes-sidebar-plugin-nav-heading" id="hermes-sidebar-plugin-nav-heading"
> >
@ -671,10 +675,12 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
cn( cn(
"group relative flex items-center gap-3", "group relative flex items-center gap-3",
"px-5 py-2.5", "px-5 py-2.5",
"font-mondwest text-[0.8rem] tracking-[0.12em]", "font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer", "whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive ? "text-midground" : "opacity-60 hover:opacity-100", isActive
? "text-midground"
: "text-text-secondary hover:text-midground",
) )
} }
style={{ style={{
@ -746,7 +752,7 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
<span <span
className={cn( className={cn(
"px-5 pt-0.5 pb-0.5", "px-5 pt-0.5 pb-0.5",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30", "font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
)} )}
> >
{t.app.system} {t.app.system}
@ -772,12 +778,12 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
active={busy} active={busy}
className={cn( className={cn(
"gap-3 px-5 py-1.5 whitespace-nowrap", "gap-3 px-5 py-1.5 whitespace-nowrap",
"font-mondwest text-[0.75rem] tracking-[0.1em]", "font-mondwest text-display text-xs tracking-[0.1em]",
"transition-opacity", "transition-colors",
busy busy
? "text-midground opacity-100" ? "text-midground"
: "opacity-60 hover:opacity-100", : "text-text-secondary hover:text-midground",
"disabled:opacity-30", "disabled:text-text-disabled",
)} )}
> >
{isPending ? ( {isPending ? (

View File

@ -11,8 +11,8 @@ function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; sch
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>} {keyPath && <span className="text-xs font-mono text-text-tertiary">{keyPath}</span>}
{description && <span className="text-xs text-muted-foreground/70">{description}</span>} {description && <span className="text-xs text-text-secondary">{description}</span>}
</div> </div>
); );
} }

View File

@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Typography } from "@/components/NouiTypography"; import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils"; import { cn, themedBody } from "@/lib/utils";
const CLOSE_DRAG_MIN_PX = 72; const CLOSE_DRAG_MIN_PX = 72;
const CLOSE_DRAG_RATIO = 0.18; const CLOSE_DRAG_RATIO = 0.18;
@ -168,6 +168,7 @@ export function BottomPickSheet({
aria-modal="true" aria-modal="true"
ref={sheetRef} ref={sheetRef}
className={cn( className={cn(
themedBody,
"relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20", "relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20",
"bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]", "bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]",
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md", "shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
@ -200,7 +201,7 @@ export function BottomPickSheet({
<Typography <Typography
mondwest mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70" className="text-display text-xs tracking-[0.12em] text-text-tertiary"
> >
{title} {title}
</Typography> </Typography>

View File

@ -304,13 +304,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 normal-case lg:w-80", "flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
className, className,
)} )}
> >
<Card className="flex items-center justify-between gap-2 px-3 py-2"> <Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground"> <div className="text-display text-xs tracking-wider text-text-tertiary">
model model
</div> </div>
@ -321,7 +321,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
onClick={() => setModelOpen(true)} onClick={() => setModelOpen(true)}
suffix={ suffix={
canPickModel ? ( canPickModel ? (
<ChevronDown className="opacity-60" /> <ChevronDown className="text-text-secondary" />
) : undefined ) : undefined
} }
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline" className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
@ -357,13 +357,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
)} )}
<Card className="flex min-h-0 flex-none flex-col px-2 py-2"> <Card className="flex min-h-0 flex-none flex-col px-2 py-2">
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground"> <div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
tools tools
</div> </div>
<div className="flex min-h-0 flex-col gap-1.5"> <div className="flex min-h-0 flex-col gap-1.5">
{tools.length === 0 ? ( {tools.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-muted-foreground"> <div className="px-2 py-4 text-center text-xs text-text-secondary">
no tool calls yet no tool calls yet
</div> </div>
) : ( ) : (

View File

@ -69,12 +69,12 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
aria-label={t.language.switchTo} aria-label={t.language.switchTo}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground" className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
> >
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Typography <Typography
mondwest mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]" className="hidden sm:inline text-display tracking-wide text-xs"
> >
{locale === "en" ? "EN" : current.name} {locale === "en" ? "EN" : current.name}
</Typography> </Typography>

View File

@ -60,11 +60,11 @@ export function ModelInfoCard({
{formatTokenCount(info.effective_context_length)} {formatTokenCount(info.effective_context_length)}
</span> </span>
{info.config_context_length > 0 ? ( {info.config_context_length > 0 ? (
<span className="text-amber-500/80 text-[10px]"> <span className="text-amber-500 text-xs">
(override auto: {formatTokenCount(info.auto_context_length)}) (override auto: {formatTokenCount(info.auto_context_length)})
</span> </span>
) : ( ) : (
<span className="text-muted-foreground/60 text-[10px]"> <span className="text-text-tertiary text-xs">
auto-detected auto-detected
</span> </span>
)} )}
@ -86,22 +86,22 @@ export function ModelInfoCard({
{hasCaps && ( {hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5"> <div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && ( {caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"> <span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools <Wrench className="h-2.5 w-2.5" /> Tools
</span> </span>
)} )}
{caps.supports_vision && ( {caps.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> <span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision <Eye className="h-2.5 w-2.5" /> Vision
</span> </span>
)} )}
{caps.supports_reasoning && ( {caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400"> <span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning <Brain className="h-2.5 w-2.5" /> Reasoning
</span> </span>
)} )}
{caps.model_family && ( {caps.model_family && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> <span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-xs font-medium text-text-secondary">
{caps.model_family} {caps.model_family}
</span> </span>
)} )}

View File

@ -8,6 +8,7 @@ import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Search, X } from "lucide-react"; import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
/** /**
* Two-stage model picker modal. * Two-stage model picker modal.
@ -212,7 +213,7 @@ export function ModelPickerDialog(props: Props) {
aria-modal="true" aria-modal="true"
aria-labelledby="model-picker-title" 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"> <div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button <Button
ghost ghost
size="icon" size="icon"
@ -226,7 +227,7 @@ export function ModelPickerDialog(props: Props) {
<header className="p-5 pb-3 border-b border-border"> <header className="p-5 pb-3 border-b border-border">
<h2 <h2
id="model-picker-title" id="model-picker-title"
className="font-display text-base tracking-wider uppercase" className="font-mondwest text-display text-base tracking-wider"
> >
{title} {title}
</h2> </h2>
@ -295,7 +296,7 @@ export function ModelPickerDialog(props: Props) {
/> />
<Label <Label
className="font-sans normal-case tracking-normal text-xs text-muted-foreground cursor-pointer" className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
htmlFor="model-picker-persist-global" htmlFor="model-picker-persist-global"
> >
Persist globally (otherwise this session only) Persist globally (otherwise this session only)
@ -375,7 +376,7 @@ function ProviderColumn({
<span className="font-medium truncate">{p.name}</span> <span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />} {p.is_current && <CurrentTag />}
</div> </div>
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate"> <div className="text-xs text-text-secondary font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models {p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div> </div>
</div> </div>
@ -462,7 +463,7 @@ function ModelColumn({
function CurrentTag() { function CurrentTag() {
return ( return (
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0"> <span className="text-display text-xs tracking-wider text-primary shrink-0">
current current
</span> </span>
); );

View File

@ -7,6 +7,7 @@ import { H2 } from "@/components/NouiTypography";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { cn, themedBody } from "@/lib/utils";
interface Props { interface Props {
provider: OAuthProvider; provider: OAuthProvider;
@ -169,7 +170,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
aria-modal="true" aria-modal="true"
aria-labelledby="oauth-modal-title" aria-labelledby="oauth-modal-title"
> >
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl"> <div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
<Button <Button
ghost ghost
size="icon" size="icon"

View File

@ -4,9 +4,7 @@ import {
ShieldOff, ShieldOff,
ExternalLink, ExternalLink,
RefreshCw, RefreshCw,
LogOut,
Terminal, Terminal,
LogIn,
} from "lucide-react"; } from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api"; import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
@ -105,13 +103,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
</CardTitle> </CardTitle>
</div> </div>
<Button <Button
size="sm" ghost
outlined size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={refresh} onClick={refresh}
disabled={loading} disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />} aria-label={t.common.refresh}
> >
{t.common.refresh} {loading ? <Spinner /> : <RefreshCw />}
</Button> </Button>
</div> </div>
<CardDescription> <CardDescription>
@ -154,46 +153,57 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
<span className="font-medium text-sm">{p.name}</span> <span className="font-medium text-sm">{p.name}</span>
<Badge <Badge
tone="outline" tone="outline"
className="text-[11px] uppercase tracking-wide" className="text-xs tracking-wide"
> >
{t.oauth.flowLabels[p.flow]} {t.oauth.flowLabels[p.flow]}
</Badge> </Badge>
{p.status.logged_in && ( {p.status.logged_in && (
<Badge tone="success" className="text-[11px]"> <Badge tone="success" className="text-xs">
{t.oauth.connected} {t.oauth.connected}
</Badge> </Badge>
)} )}
{expiresLabel === "expired" && ( {expiresLabel === "expired" && (
<Badge tone="destructive" className="text-[11px]"> <Badge tone="destructive" className="text-xs">
{t.oauth.expired} {t.oauth.expired}
</Badge> </Badge>
)} )}
{expiresLabel && expiresLabel !== "expired" && ( {expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-[11px]"> <Badge tone="outline" className="text-xs">
{expiresLabel} {expiresLabel}
</Badge> </Badge>
)} )}
</div> </div>
{p.status.logged_in && p.status.token_preview && ( {p.status.logged_in && p.status.token_preview && (
<code className="text-xs font-mono-ui truncate"> <span className="truncate text-xs font-mono-ui text-text-secondary">
<span className="opacity-50">token </span> <span className="text-text-tertiary">token </span>
{p.status.token_preview} {p.status.token_preview}
{p.status.source_label && ( {p.status.source_label && (
<span className="opacity-40"> <span className="text-text-tertiary">
{" "} {" "}
· {p.status.source_label} · {p.status.source_label}
</span> </span>
)} )}
</code> </span>
)} )}
{!p.status.logged_in && ( {!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80"> <>
{t.oauth.notConnected.split("{command}")[0]} <span className="text-xs text-text-secondary">
<code className="text-foreground bg-secondary/40 px-1"> {t.oauth.notConnected.split("{command}")[0].trimEnd()}
{p.cli_command} {t.oauth.notConnected.split("{command}")[1] ?? ""}
</code> </span>
{t.oauth.notConnected.split("{command}")[1]}
</span> <div className="flex min-w-0 flex-wrap items-center gap-2">
<code className="font-courier truncate text-xs opacity-60">
{p.cli_command}
</code>
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
</div>
</>
)} )}
{p.status.error && ( {p.status.error && (
<span className="text-xs text-destructive"> <span className="text-xs text-destructive">
@ -220,32 +230,26 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
{!p.status.logged_in && p.flow !== "external" && ( {!p.status.logged_in && p.flow !== "external" && (
<Button <Button
size="sm" size="sm"
className="uppercase"
onClick={() => setLoginFor(p)} onClick={() => setLoginFor(p)}
prefix={<LogIn />}
> >
{t.oauth.login} {t.oauth.login}
</Button> </Button>
)} )}
{!p.status.logged_in && (
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
)}
{p.status.logged_in && p.flow !== "external" && ( {p.status.logged_in && p.flow !== "external" && (
<Button <Button
size="sm" size="sm"
outlined outlined
className="uppercase"
onClick={() => setDisconnectTarget(p)} onClick={() => setDisconnectTarget(p)}
disabled={isBusy} disabled={isBusy}
prefix={isBusy ? <Spinner /> : <LogOut />} prefix={isBusy ? <Spinner /> : undefined}
> >
{t.oauth.disconnect} {t.oauth.disconnect}
</Button> </Button>
)} )}
{p.status.logged_in && p.flow === "external" && ( {p.status.logged_in && p.flow === "external" && (
<span className="text-[11px] text-muted-foreground italic px-2"> <span className="text-xs text-text-tertiary italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" /> <Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally} {t.oauth.managedExternally}
</span> </span>

View File

@ -57,18 +57,18 @@ export function PlatformsCard({ platforms }: PlatformsCardProps) {
/> />
<div className="flex flex-col gap-0.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate"> <span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
{name} {name}
</span> </span>
{info.error_message && ( {info.error_message && (
<span className="text-xs text-destructive"> <span className="font-mondwest normal-case text-xs text-destructive">
{info.error_message} {info.error_message}
</span> </span>
)} )}
{info.updated_at && ( {info.updated_at && (
<span className="text-xs text-muted-foreground"> <span className="font-mondwest normal-case text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)} {t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span> </span>
)} )}

View File

@ -16,8 +16,7 @@ export function SidebarFooter() {
)} )}
> >
<Typography <Typography
mondwest className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70 lowercase"
> >
{status?.version != null ? `v${status.version}` : "—"} {status?.version != null ? `v${status.version}` : "—"}
</Typography> </Typography>
@ -27,7 +26,7 @@ export function SidebarFooter() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground", "font-mondwest text-display text-xs tracking-[0.12em] text-midground",
"transition-opacity hover:opacity-90", "transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40", "focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)} )}

View File

@ -27,21 +27,21 @@ export function SidebarStatusStrip() {
className={cn( className={cn(
"block text-left", "block text-left",
"px-5 pb-2 pt-0.5", "px-5 pb-2 pt-0.5",
"text-muted-foreground/70", "text-text-secondary",
"transition-colors hover:text-muted-foreground/90", "transition-colors hover:text-midground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
"focus-visible:ring-inset", "focus-visible:ring-inset",
)} )}
> >
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]"> <div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
<p className="break-words"> <p className="break-words">
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "} <span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span> <span className={cn("font-medium", gw.tone)}>{gw.label}</span>
</p> </p>
<p className="break-words"> <p className="break-words">
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "} <span className="text-text-tertiary">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-muted-foreground/70"> <span className="tabular-nums text-text-secondary">
{status.active_sessions} {status.active_sessions}
</span> </span>
</p> </p>

View File

@ -158,7 +158,7 @@ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
</span> </span>
{it.meta && ( {it.meta && (
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto"> <span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta} {it.meta}
</span> </span>
)} )}

View File

@ -65,7 +65,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<Button <Button
ghost ghost
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground" className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"} title={t.theme?.switchTheme ?? "Switch theme"}
aria-label={t.theme?.switchTheme ?? "Switch theme"} aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open} aria-expanded={open}
@ -76,7 +76,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<Typography <Typography
mondwest mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]" className="hidden sm:inline text-display tracking-wide text-xs"
> >
{label} {label}
</Typography> </Typography>
@ -115,7 +115,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<div className="border-b border-current/20 px-3 py-2"> <div className="border-b border-current/20 px-3 py-2">
<Typography <Typography
mondwest mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70" className="text-display text-xs tracking-[0.12em] text-text-tertiary"
> >
{sheetTitle} {sheetTitle}
</Typography> </Typography>
@ -166,12 +166,12 @@ function ThemeSwitcherOptions({
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography <Typography
mondwest mondwest
className="truncate text-[0.75rem] tracking-wide uppercase" className="truncate text-display text-xs tracking-wide"
> >
{th.label} {th.label}
</Typography> </Typography>
{th.description && ( {th.description && (
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50"> <Typography className="truncate text-xs tracking-normal text-text-tertiary">
{th.description} {th.description}
</Typography> </Typography>
)} )}

View File

@ -104,7 +104,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
<span className="font-mono font-medium shrink-0">{tool.name}</span> <span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1"> <span className="font-mono text-text-secondary truncate min-w-0 flex-1">
{tool.context ?? ""} {tool.context ?? ""}
</span> </span>
@ -128,7 +128,7 @@ export function ToolCall({ tool }: { tool: ToolEntry }) {
)} )}
{elapsed && ( {elapsed && (
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0"> <span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
{elapsed} {elapsed}
</span> </span>
)} )}
@ -186,8 +186,8 @@ function Section({
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
<span <span
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${ className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60" tone === "error" ? "text-destructive" : "text-text-tertiary"
}`} }`}
> >
{label} {label}
@ -224,5 +224,5 @@ function diffLineClass(line: string): string {
if (line.startsWith("-") && !line.startsWith("---")) if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive"; return "text-destructive";
if (line.startsWith("@@")) return "text-primary"; if (line.startsWith("@@")) return "text-primary";
return "text-muted-foreground/80"; return "text-text-secondary";
} }

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"; import { cn, themedBody } from "@/lib/utils";
/** /**
* Themed card primitive. Themes can restyle every card without touching * Themed card primitive. Themes can restyle every card without touching
@ -27,6 +27,7 @@ export function Card({ className, style, ...props }: React.HTMLAttributes<HTMLDi
<div <div
className={cn( className={cn(
"border border-border bg-card/80 text-card-foreground w-full", "border border-border bg-card/80 text-card-foreground w-full",
themedBody,
className, className,
)} )}
style={{ ...CARD_STYLE, ...style }} style={{ ...CARD_STYLE, ...style }}
@ -40,11 +41,21 @@ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDiv
} }
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />; return (
<h3
className={cn(
"font-mondwest text-display text-sm tracking-[0.12em] text-text-primary",
className,
)}
{...props}
/>
);
} }
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) { export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-mondwest text-xs text-muted-foreground", className)} {...props} />; return (
<p className={cn("font-mondwest normal-case text-xs text-muted-foreground", className)} {...props} />
);
} }
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {

View File

@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
import { cn } from "@/lib/utils"; import { cn, themedBody } from "@/lib/utils";
export function ConfirmDialog({ export function ConfirmDialog({
cancelLabel = "Cancel", cancelLabel = "Cancel",
@ -64,6 +64,7 @@ export function ConfirmDialog({
<div <div
ref={dialogRef} ref={dialogRef}
className={cn( className={cn(
themedBody,
"relative w-full max-w-md mx-4", "relative w-full max-w-md mx-4",
"border border-border bg-card shadow-lg", "border border-border bg-card shadow-lg",
"animate-[dialog-in_180ms_ease-out]", "animate-[dialog-in_180ms_ease-out]",
@ -82,7 +83,7 @@ export function ConfirmDialog({
<div className="flex-1 min-w-0 flex flex-col gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-1">
<h2 <h2
id="confirm-dialog-title" id="confirm-dialog-title"
className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter" className="font-mondwest text-display text-sm font-bold tracking-[0.12em] blend-lighter"
> >
{title} {title}
</h2> </h2>
@ -90,7 +91,7 @@ export function ConfirmDialog({
{description && ( {description && (
<p <p
id="confirm-dialog-desc" id="confirm-dialog-desc"
className="font-mondwest text-xs text-muted-foreground leading-relaxed" className="font-mondwest normal-case text-xs text-muted-foreground leading-relaxed"
> >
{description} {description}
</p> </p>

View File

@ -127,6 +127,7 @@ export const af: Translations = {
sessions: { sessions: {
title: "Sessies", title: "Sessies",
overview: "Oorsig",
searchPlaceholder: "Soek boodskap-inhoud...", searchPlaceholder: "Soek boodskap-inhoud...",
noSessions: "Nog geen sessies nie", noSessions: "Nog geen sessies nie",
noMatch: "Geen sessies stem ooreen met jou soektog nie", noMatch: "Geen sessies stem ooreen met jou soektog nie",
@ -269,7 +270,7 @@ export const af: Translations = {
"Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).", "Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).",
identifierLabel: "Git-URL of owner/repo", identifierLabel: "Git-URL of owner/repo",
inactive: "onaktief", inactive: "onaktief",
installBtn: "Installeer vanaf Git", installBtn: "Installeer",
installHeading: "Installeer vanaf GitHub / Git-URL", installHeading: "Installeer vanaf GitHub / Git-URL",
installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.", installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.",
memoryProviderLabel: "Geheueverskaffer", memoryProviderLabel: "Geheueverskaffer",
@ -367,6 +368,8 @@ export const af: Translations = {
description: "Bestuur API-sleutels en geheime gestoor in", description: "Bestuur API-sleutels en geheime gestoor in",
hideAdvanced: "Versteek Gevorderd", hideAdvanced: "Versteek Gevorderd",
showAdvanced: "Wys Gevorderd", showAdvanced: "Wys Gevorderd",
showLess: "Wys minder",
showMore: "Wys meer",
llmProviders: "LLM-verskaffers", llmProviders: "LLM-verskaffers",
providersConfigured: "{configured} van {total} verskaffers gekonfigureer", providersConfigured: "{configured} van {total} verskaffers gekonfigureer",
getKey: "Kry sleutel", getKey: "Kry sleutel",
@ -392,7 +395,7 @@ export const af: Translations = {
disconnect: "Ontkoppel", disconnect: "Ontkoppel",
managedExternally: "Ekstern bestuur", managedExternally: "Ekstern bestuur",
copied: "Gekopieer ✓", copied: "Gekopieer ✓",
cli: "CLI", cli: "Kopieer",
copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)", copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
connect: "Koppel", connect: "Koppel",
sessionExpires: "Sessie verval oor {time}", sessionExpires: "Sessie verval oor {time}",

View File

@ -127,6 +127,7 @@ export const de: Translations = {
sessions: { sessions: {
title: "Sitzungen", title: "Sitzungen",
overview: "Übersicht",
searchPlaceholder: "Nachrichteninhalt suchen...", searchPlaceholder: "Nachrichteninhalt suchen...",
noSessions: "Noch keine Sitzungen", noSessions: "Noch keine Sitzungen",
noMatch: "Keine Sitzungen entsprechen deiner Suche", noMatch: "Keine Sitzungen entsprechen deiner Suche",
@ -269,7 +270,7 @@ export const de: Translations = {
"Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).", "Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).",
identifierLabel: "Git-URL oder owner/repo", identifierLabel: "Git-URL oder owner/repo",
inactive: "inaktiv", inactive: "inaktiv",
installBtn: "Aus Git installieren", installBtn: "Installieren",
installHeading: "Aus GitHub / Git-URL installieren", installHeading: "Aus GitHub / Git-URL installieren",
installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.", installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.",
memoryProviderLabel: "Speicheranbieter", memoryProviderLabel: "Speicheranbieter",
@ -367,6 +368,8 @@ export const de: Translations = {
description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind", description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind",
hideAdvanced: "Erweitert ausblenden", hideAdvanced: "Erweitert ausblenden",
showAdvanced: "Erweitert anzeigen", showAdvanced: "Erweitert anzeigen",
showLess: "Weniger anzeigen",
showMore: "Mehr anzeigen",
llmProviders: "LLM-Anbieter", llmProviders: "LLM-Anbieter",
providersConfigured: "{configured} von {total} Anbietern konfiguriert", providersConfigured: "{configured} von {total} Anbietern konfiguriert",
getKey: "Schlüssel holen", getKey: "Schlüssel holen",
@ -392,7 +395,7 @@ export const de: Translations = {
disconnect: "Trennen", disconnect: "Trennen",
managedExternally: "Extern verwaltet", managedExternally: "Extern verwaltet",
copied: "Kopiert ✓", copied: "Kopiert ✓",
cli: "CLI", cli: "Kopieren",
copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)", copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
connect: "Verbinden", connect: "Verbinden",
sessionExpires: "Sitzung läuft in {time} ab", sessionExpires: "Sitzung läuft in {time} ab",

View File

@ -127,6 +127,7 @@ export const en: Translations = {
sessions: { sessions: {
title: "Sessions", title: "Sessions",
overview: "Overview",
searchPlaceholder: "Search message content...", searchPlaceholder: "Search message content...",
noSessions: "No sessions yet", noSessions: "No sessions yet",
noMatch: "No sessions match your search", noMatch: "No sessions match your search",
@ -269,7 +270,7 @@ export const en: Translations = {
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).", "Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo", identifierLabel: "Git URL or owner/repo",
inactive: "inactive", inactive: "inactive",
installBtn: "Install from Git", installBtn: "Install",
installHeading: "Install from GitHub / Git URL", installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.", installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider", memoryProviderLabel: "Memory provider",
@ -367,6 +368,8 @@ export const en: Translations = {
description: "Manage API keys and secrets stored in", description: "Manage API keys and secrets stored in",
hideAdvanced: "Hide Advanced", hideAdvanced: "Hide Advanced",
showAdvanced: "Show Advanced", showAdvanced: "Show Advanced",
showLess: "Show less",
showMore: "Show more",
llmProviders: "LLM Providers", llmProviders: "LLM Providers",
providersConfigured: "{configured} of {total} providers configured", providersConfigured: "{configured} of {total} providers configured",
getKey: "Get key", getKey: "Get key",
@ -392,7 +395,7 @@ export const en: Translations = {
disconnect: "Disconnect", disconnect: "Disconnect",
managedExternally: "Managed externally", managedExternally: "Managed externally",
copied: "Copied ✓", copied: "Copied ✓",
cli: "CLI", cli: "Copy",
copyCliCommand: "Copy CLI command (for external / fallback)", copyCliCommand: "Copy CLI command (for external / fallback)",
connect: "Connect", connect: "Connect",
sessionExpires: "Session expires in {time}", sessionExpires: "Session expires in {time}",

View File

@ -127,6 +127,7 @@ export const es: Translations = {
sessions: { sessions: {
title: "Sesiones", title: "Sesiones",
overview: "Resumen",
searchPlaceholder: "Buscar contenido de mensajes...", searchPlaceholder: "Buscar contenido de mensajes...",
noSessions: "Aún no hay sesiones", noSessions: "Aún no hay sesiones",
noMatch: "Ninguna sesión coincide con tu búsqueda", noMatch: "Ninguna sesión coincide con tu búsqueda",
@ -269,7 +270,7 @@ export const es: Translations = {
"Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).", "Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).",
identifierLabel: "URL de Git u owner/repo", identifierLabel: "URL de Git u owner/repo",
inactive: "inactivo", inactive: "inactivo",
installBtn: "Instalar desde Git", installBtn: "Instalar",
installHeading: "Instalar desde GitHub / URL de Git", installHeading: "Instalar desde GitHub / URL de Git",
installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.", installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.",
memoryProviderLabel: "Proveedor de memoria", memoryProviderLabel: "Proveedor de memoria",
@ -367,6 +368,8 @@ export const es: Translations = {
description: "Gestiona claves API y secretos almacenados en", description: "Gestiona claves API y secretos almacenados en",
hideAdvanced: "Ocultar avanzado", hideAdvanced: "Ocultar avanzado",
showAdvanced: "Mostrar avanzado", showAdvanced: "Mostrar avanzado",
showLess: "Mostrar menos",
showMore: "Mostrar más",
llmProviders: "Proveedores LLM", llmProviders: "Proveedores LLM",
providersConfigured: "{configured} de {total} proveedores configurados", providersConfigured: "{configured} de {total} proveedores configurados",
getKey: "Obtener clave", getKey: "Obtener clave",
@ -392,7 +395,7 @@ export const es: Translations = {
disconnect: "Desconectar", disconnect: "Desconectar",
managedExternally: "Gestionado externamente", managedExternally: "Gestionado externamente",
copied: "Copiado ✓", copied: "Copiado ✓",
cli: "CLI", cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / alternativa)", copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
connect: "Conectar", connect: "Conectar",
sessionExpires: "La sesión caduca en {time}", sessionExpires: "La sesión caduca en {time}",

View File

@ -127,6 +127,7 @@ export const fr: Translations = {
sessions: { sessions: {
title: "Sessions", title: "Sessions",
overview: "Aperçu",
searchPlaceholder: "Rechercher dans les messages...", searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour l'instant", noSessions: "Aucune session pour l'instant",
noMatch: "Aucune session ne correspond à votre recherche", noMatch: "Aucune session ne correspond à votre recherche",
@ -269,7 +270,7 @@ export const fr: Translations = {
"Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).", "Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo", identifierLabel: "URL Git ou owner/repo",
inactive: "inactif", inactive: "inactif",
installBtn: "Installer depuis Git", installBtn: "Installer",
installHeading: "Installer depuis GitHub / URL Git", installHeading: "Installer depuis GitHub / URL Git",
installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.", installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.",
memoryProviderLabel: "Fournisseur de mémoire", memoryProviderLabel: "Fournisseur de mémoire",
@ -367,6 +368,8 @@ export const fr: Translations = {
description: "Gérer les clés API et les secrets stockés dans", description: "Gérer les clés API et les secrets stockés dans",
hideAdvanced: "Masquer les options avancées", hideAdvanced: "Masquer les options avancées",
showAdvanced: "Afficher les options avancées", showAdvanced: "Afficher les options avancées",
showLess: "Afficher moins",
showMore: "Afficher plus",
llmProviders: "Fournisseurs LLM", llmProviders: "Fournisseurs LLM",
providersConfigured: "{configured} sur {total} fournisseurs configurés", providersConfigured: "{configured} sur {total} fournisseurs configurés",
getKey: "Obtenir la clé", getKey: "Obtenir la clé",
@ -392,7 +395,7 @@ export const fr: Translations = {
disconnect: "Déconnecter", disconnect: "Déconnecter",
managedExternally: "Géré en externe", managedExternally: "Géré en externe",
copied: "Copié ✓", copied: "Copié ✓",
cli: "CLI", cli: "Copier",
copyCliCommand: "Copier la commande CLI (pour externe / repli)", copyCliCommand: "Copier la commande CLI (pour externe / repli)",
connect: "Connecter", connect: "Connecter",
sessionExpires: "La session expire dans {time}", sessionExpires: "La session expire dans {time}",

View File

@ -127,6 +127,7 @@ export const ga: Translations = {
sessions: { sessions: {
title: "Seisiúin", title: "Seisiúin",
overview: "Forbhreathnú",
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...", searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
noSessions: "Gan seisiúin go fóill", noSessions: "Gan seisiúin go fóill",
noMatch: "Níl seisiún ar bith ag teacht le do chuardach", noMatch: "Níl seisiún ar bith ag teacht le do chuardach",
@ -269,7 +270,7 @@ export const ga: Translations = {
"Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).", "Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).",
identifierLabel: "URL Git nó owner/repo", identifierLabel: "URL Git nó owner/repo",
inactive: "neamhghníomhach", inactive: "neamhghníomhach",
installBtn: "Suiteáil ó Git", installBtn: "Suiteáil",
installHeading: "Suiteáil ó GitHub / URL Git", installHeading: "Suiteáil ó GitHub / URL Git",
installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.", installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.",
memoryProviderLabel: "Soláthraí cuimhne", memoryProviderLabel: "Soláthraí cuimhne",
@ -367,6 +368,8 @@ export const ga: Translations = {
description: "Bainistigh eochracha API agus rúin atá stóráilte i", description: "Bainistigh eochracha API agus rúin atá stóráilte i",
hideAdvanced: "Folaigh Ardroghanna", hideAdvanced: "Folaigh Ardroghanna",
showAdvanced: "Taispeáin Ardroghanna", showAdvanced: "Taispeáin Ardroghanna",
showLess: "Taispeáin níos lú",
showMore: "Taispeáin tuilleadh",
llmProviders: "Soláthraithe LLM", llmProviders: "Soláthraithe LLM",
providersConfigured: "{configured} as {total} soláthraí cumraithe", providersConfigured: "{configured} as {total} soláthraí cumraithe",
getKey: "Faigh eochair", getKey: "Faigh eochair",
@ -392,7 +395,7 @@ export const ga: Translations = {
disconnect: "Dícheangail", disconnect: "Dícheangail",
managedExternally: "Bainistithe go seachtrach", managedExternally: "Bainistithe go seachtrach",
copied: "Cóipeáilte ✓", copied: "Cóipeáilte ✓",
cli: "CLI", cli: "Cóipeáil",
copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)", copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
connect: "Ceangail", connect: "Ceangail",
sessionExpires: "Téann an seisiún as feidhm i {time}", sessionExpires: "Téann an seisiún as feidhm i {time}",

View File

@ -127,6 +127,7 @@ export const hu: Translations = {
sessions: { sessions: {
title: "Munkamenetek", title: "Munkamenetek",
overview: "Áttekintés",
searchPlaceholder: "Keresés üzenettartalomban...", searchPlaceholder: "Keresés üzenettartalomban...",
noSessions: "Még nincsenek munkamenetek", noSessions: "Még nincsenek munkamenetek",
noMatch: "Nincs a keresésnek megfelelő munkamenet", noMatch: "Nincs a keresésnek megfelelő munkamenet",
@ -269,7 +270,7 @@ export const hu: Translations = {
"Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).", "Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).",
identifierLabel: "Git URL vagy owner/repo", identifierLabel: "Git URL vagy owner/repo",
inactive: "inaktív", inactive: "inaktív",
installBtn: "Telepítés Gitből", installBtn: "Telepítés",
installHeading: "Telepítés GitHubról / Git URL-ről", installHeading: "Telepítés GitHubról / Git URL-ről",
installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.", installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.",
memoryProviderLabel: "Memória-szolgáltató", memoryProviderLabel: "Memória-szolgáltató",
@ -367,6 +368,8 @@ export const hu: Translations = {
description: "API-kulcsok és titkok kezelése a következő helyen:", description: "API-kulcsok és titkok kezelése a következő helyen:",
hideAdvanced: "Speciális elrejtése", hideAdvanced: "Speciális elrejtése",
showAdvanced: "Speciális megjelenítése", showAdvanced: "Speciális megjelenítése",
showLess: "Kevesebb",
showMore: "Több",
llmProviders: "LLM-szolgáltatók", llmProviders: "LLM-szolgáltatók",
providersConfigured: "{configured} / {total} szolgáltató beállítva", providersConfigured: "{configured} / {total} szolgáltató beállítva",
getKey: "Kulcs lekérése", getKey: "Kulcs lekérése",
@ -392,7 +395,7 @@ export const hu: Translations = {
disconnect: "Lecsatlakozás", disconnect: "Lecsatlakozás",
managedExternally: "Külsőleg kezelt", managedExternally: "Külsőleg kezelt",
copied: "Másolva ✓", copied: "Másolva ✓",
cli: "CLI", cli: "Másolás",
copyCliCommand: "CLI-parancs másolása (külső / tartalék)", copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
connect: "Csatlakozás", connect: "Csatlakozás",
sessionExpires: "A munkamenet {time} múlva lejár", sessionExpires: "A munkamenet {time} múlva lejár",

View File

@ -127,6 +127,7 @@ export const it: Translations = {
sessions: { sessions: {
title: "Sessioni", title: "Sessioni",
overview: "Panoramica",
searchPlaceholder: "Cerca nel contenuto dei messaggi...", searchPlaceholder: "Cerca nel contenuto dei messaggi...",
noSessions: "Nessuna sessione", noSessions: "Nessuna sessione",
noMatch: "Nessuna sessione corrisponde alla ricerca", noMatch: "Nessuna sessione corrisponde alla ricerca",
@ -269,7 +270,7 @@ export const it: Translations = {
"Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).", "Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).",
identifierLabel: "URL Git o owner/repo", identifierLabel: "URL Git o owner/repo",
inactive: "inattivo", inactive: "inattivo",
installBtn: "Installa da Git", installBtn: "Installa",
installHeading: "Installa da GitHub / URL Git", installHeading: "Installa da GitHub / URL Git",
installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.", installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.",
memoryProviderLabel: "Provider di memoria", memoryProviderLabel: "Provider di memoria",
@ -367,6 +368,8 @@ export const it: Translations = {
description: "Gestisci chiavi API e segreti memorizzati in", description: "Gestisci chiavi API e segreti memorizzati in",
hideAdvanced: "Nascondi avanzate", hideAdvanced: "Nascondi avanzate",
showAdvanced: "Mostra avanzate", showAdvanced: "Mostra avanzate",
showLess: "Mostra meno",
showMore: "Mostra di più",
llmProviders: "Provider LLM", llmProviders: "Provider LLM",
providersConfigured: "{configured} di {total} provider configurati", providersConfigured: "{configured} di {total} provider configurati",
getKey: "Ottieni chiave", getKey: "Ottieni chiave",
@ -392,7 +395,7 @@ export const it: Translations = {
disconnect: "Disconnetti", disconnect: "Disconnetti",
managedExternally: "Gestito esternamente", managedExternally: "Gestito esternamente",
copied: "Copiato ✓", copied: "Copiato ✓",
cli: "CLI", cli: "Copia",
copyCliCommand: "Copia comando CLI (per uso esterno / fallback)", copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
connect: "Connetti", connect: "Connetti",
sessionExpires: "La sessione scade tra {time}", sessionExpires: "La sessione scade tra {time}",

View File

@ -127,6 +127,7 @@ export const ja: Translations = {
sessions: { sessions: {
title: "セッション", title: "セッション",
overview: "概要",
searchPlaceholder: "メッセージ内容を検索...", searchPlaceholder: "メッセージ内容を検索...",
noSessions: "まだセッションがありません", noSessions: "まだセッションがありません",
noMatch: "検索条件に一致するセッションはありません", noMatch: "検索条件に一致するセッションはありません",
@ -269,7 +270,7 @@ export const ja: Translations = {
"Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。", "Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。",
identifierLabel: "Git URL または owner/repo", identifierLabel: "Git URL または owner/repo",
inactive: "非アクティブ", inactive: "非アクティブ",
installBtn: "Git からインストール", installBtn: "インストール",
installHeading: "GitHub / Git URL からインストール", installHeading: "GitHub / Git URL からインストール",
installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。", installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。",
memoryProviderLabel: "メモリプロバイダー", memoryProviderLabel: "メモリプロバイダー",
@ -367,6 +368,8 @@ export const ja: Translations = {
description: "API キーとシークレットを管理します。保存先:", description: "API キーとシークレットを管理します。保存先:",
hideAdvanced: "詳細設定を隠す", hideAdvanced: "詳細設定を隠す",
showAdvanced: "詳細設定を表示", showAdvanced: "詳細設定を表示",
showLess: "表示を減らす",
showMore: "もっと見る",
llmProviders: "LLM プロバイダー", llmProviders: "LLM プロバイダー",
providersConfigured: "{configured} / {total} プロバイダーが設定済み", providersConfigured: "{configured} / {total} プロバイダーが設定済み",
getKey: "キーを取得", getKey: "キーを取得",
@ -392,7 +395,7 @@ export const ja: Translations = {
disconnect: "切断", disconnect: "切断",
managedExternally: "外部で管理", managedExternally: "外部で管理",
copied: "コピーしました ✓", copied: "コピーしました ✓",
cli: "CLI", cli: "コピー",
copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)", copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
connect: "接続", connect: "接続",
sessionExpires: "セッションは {time} 後に期限切れになります", sessionExpires: "セッションは {time} 後に期限切れになります",

View File

@ -127,6 +127,7 @@ export const ko: Translations = {
sessions: { sessions: {
title: "세션", title: "세션",
overview: "개요",
searchPlaceholder: "메시지 내용 검색...", searchPlaceholder: "메시지 내용 검색...",
noSessions: "아직 세션이 없습니다", noSessions: "아직 세션이 없습니다",
noMatch: "검색과 일치하는 세션이 없습니다", noMatch: "검색과 일치하는 세션이 없습니다",
@ -269,7 +270,7 @@ export const ko: Translations = {
"Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).", "Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).",
identifierLabel: "Git URL 또는 owner/repo", identifierLabel: "Git URL 또는 owner/repo",
inactive: "비활성", inactive: "비활성",
installBtn: "Git에서 설치", installBtn: "설치",
installHeading: "GitHub / Git URL에서 설치", installHeading: "GitHub / Git URL에서 설치",
installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.", installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.",
memoryProviderLabel: "메모리 제공자", memoryProviderLabel: "메모리 제공자",
@ -367,6 +368,8 @@ export const ko: Translations = {
description: "다음 위치에 저장된 API 키와 비밀을 관리합니다", description: "다음 위치에 저장된 API 키와 비밀을 관리합니다",
hideAdvanced: "고급 숨기기", hideAdvanced: "고급 숨기기",
showAdvanced: "고급 표시", showAdvanced: "고급 표시",
showLess: "간략히",
showMore: "더 보기",
llmProviders: "LLM 제공자", llmProviders: "LLM 제공자",
providersConfigured: "{configured}/{total} 제공자가 구성됨", providersConfigured: "{configured}/{total} 제공자가 구성됨",
getKey: "키 받기", getKey: "키 받기",
@ -392,7 +395,7 @@ export const ko: Translations = {
disconnect: "연결 해제", disconnect: "연결 해제",
managedExternally: "외부에서 관리됨", managedExternally: "외부에서 관리됨",
copied: "복사됨 ✓", copied: "복사됨 ✓",
cli: "CLI", cli: "복사",
copyCliCommand: "CLI 명령 복사 (외부 / 대체용)", copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
connect: "연결", connect: "연결",
sessionExpires: "세션이 {time} 후 만료됩니다", sessionExpires: "세션이 {time} 후 만료됩니다",

View File

@ -127,6 +127,7 @@ export const pt: Translations = {
sessions: { sessions: {
title: "Sessões", title: "Sessões",
overview: "Visão geral",
searchPlaceholder: "Pesquisar conteúdo das mensagens...", searchPlaceholder: "Pesquisar conteúdo das mensagens...",
noSessions: "Ainda não há sessões", noSessions: "Ainda não há sessões",
noMatch: "Nenhuma sessão corresponde à pesquisa", noMatch: "Nenhuma sessão corresponde à pesquisa",
@ -269,7 +270,7 @@ export const pt: Translations = {
"Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).", "Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).",
identifierLabel: "URL Git ou owner/repo", identifierLabel: "URL Git ou owner/repo",
inactive: "inativo", inactive: "inativo",
installBtn: "Instalar a partir do Git", installBtn: "Instalar",
installHeading: "Instalar a partir de GitHub / URL Git", installHeading: "Instalar a partir de GitHub / URL Git",
installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.", installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.",
memoryProviderLabel: "Fornecedor de memória", memoryProviderLabel: "Fornecedor de memória",
@ -367,6 +368,8 @@ export const pt: Translations = {
description: "Gerir chaves de API e segredos armazenados em", description: "Gerir chaves de API e segredos armazenados em",
hideAdvanced: "Ocultar avançadas", hideAdvanced: "Ocultar avançadas",
showAdvanced: "Mostrar avançadas", showAdvanced: "Mostrar avançadas",
showLess: "Mostrar menos",
showMore: "Mostrar mais",
llmProviders: "Fornecedores LLM", llmProviders: "Fornecedores LLM",
providersConfigured: "{configured} de {total} fornecedores configurados", providersConfigured: "{configured} de {total} fornecedores configurados",
getKey: "Obter chave", getKey: "Obter chave",
@ -392,7 +395,7 @@ export const pt: Translations = {
disconnect: "Desligar", disconnect: "Desligar",
managedExternally: "Gerido externamente", managedExternally: "Gerido externamente",
copied: "Copiado ✓", copied: "Copiado ✓",
cli: "CLI", cli: "Copiar",
copyCliCommand: "Copiar comando CLI (para externo / fallback)", copyCliCommand: "Copiar comando CLI (para externo / fallback)",
connect: "Ligar", connect: "Ligar",
sessionExpires: "A sessão expira em {time}", sessionExpires: "A sessão expira em {time}",

View File

@ -127,6 +127,7 @@ export const ru: Translations = {
sessions: { sessions: {
title: "Сессии", title: "Сессии",
overview: "Обзор",
searchPlaceholder: "Поиск по содержимому сообщений...", searchPlaceholder: "Поиск по содержимому сообщений...",
noSessions: "Сессий пока нет", noSessions: "Сессий пока нет",
noMatch: "Нет сессий, соответствующих запросу", noMatch: "Нет сессий, соответствующих запросу",
@ -269,7 +270,7 @@ export const ru: Translations = {
"Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).", "Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).",
identifierLabel: "Git URL или owner/repo", identifierLabel: "Git URL или owner/repo",
inactive: "неактивно", inactive: "неактивно",
installBtn: "Установить из Git", installBtn: "Установить",
installHeading: "Установка из GitHub / Git URL", installHeading: "Установка из GitHub / Git URL",
installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.", installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.",
memoryProviderLabel: "Провайдер памяти", memoryProviderLabel: "Провайдер памяти",
@ -367,6 +368,8 @@ export const ru: Translations = {
description: "Управление API-ключами и секретами, хранящимися в", description: "Управление API-ключами и секретами, хранящимися в",
hideAdvanced: "Скрыть расширенные", hideAdvanced: "Скрыть расширенные",
showAdvanced: "Показать расширенные", showAdvanced: "Показать расширенные",
showLess: "Показать меньше",
showMore: "Показать больше",
llmProviders: "Провайдеры LLM", llmProviders: "Провайдеры LLM",
providersConfigured: "Настроено {configured} из {total} провайдеров", providersConfigured: "Настроено {configured} из {total} провайдеров",
getKey: "Получить ключ", getKey: "Получить ключ",
@ -392,7 +395,7 @@ export const ru: Translations = {
disconnect: "Отключить", disconnect: "Отключить",
managedExternally: "Управляется извне", managedExternally: "Управляется извне",
copied: "Скопировано ✓", copied: "Скопировано ✓",
cli: "CLI", cli: "Копировать",
copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)", copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
connect: "Подключить", connect: "Подключить",
sessionExpires: "Сессия истечёт через {time}", sessionExpires: "Сессия истечёт через {time}",

View File

@ -127,6 +127,7 @@ export const tr: Translations = {
sessions: { sessions: {
title: "Oturumlar", title: "Oturumlar",
overview: "Genel bakış",
searchPlaceholder: "Mesaj içeriğinde ara...", searchPlaceholder: "Mesaj içeriğinde ara...",
noSessions: "Henüz oturum yok", noSessions: "Henüz oturum yok",
noMatch: "Aramanızla eşleşen oturum yok", noMatch: "Aramanızla eşleşen oturum yok",
@ -269,7 +270,7 @@ export const tr: Translations = {
"Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).", "Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).",
identifierLabel: "Git URL veya owner/repo", identifierLabel: "Git URL veya owner/repo",
inactive: "pasif", inactive: "pasif",
installBtn: "Git'ten yükle", installBtn: "Yükle",
installHeading: "GitHub / Git URL'sinden yükle", installHeading: "GitHub / Git URL'sinden yükle",
installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.", installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.",
memoryProviderLabel: "Bellek sağlayıcısı", memoryProviderLabel: "Bellek sağlayıcısı",
@ -367,6 +368,8 @@ export const tr: Translations = {
description: "Şurada saklanan API anahtarlarını ve sırları yönetin", description: "Şurada saklanan API anahtarlarını ve sırları yönetin",
hideAdvanced: "Gelişmişi Gizle", hideAdvanced: "Gelişmişi Gizle",
showAdvanced: "Gelişmişi Göster", showAdvanced: "Gelişmişi Göster",
showLess: "Daha az göster",
showMore: "Daha fazla göster",
llmProviders: "LLM Sağlayıcıları", llmProviders: "LLM Sağlayıcıları",
providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı", providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı",
getKey: "Anahtar al", getKey: "Anahtar al",
@ -392,7 +395,7 @@ export const tr: Translations = {
disconnect: "Bağlantıyı kes", disconnect: "Bağlantıyı kes",
managedExternally: "Harici olarak yönetiliyor", managedExternally: "Harici olarak yönetiliyor",
copied: "Kopyalandı ✓", copied: "Kopyalandı ✓",
cli: "CLI", cli: "Kopyala",
copyCliCommand: "CLI komutunu kopyala (harici / yedek için)", copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
connect: "Bağlan", connect: "Bağlan",
sessionExpires: "Oturumun süresi {time} sonra dolacak", sessionExpires: "Oturumun süresi {time} sonra dolacak",

View File

@ -145,6 +145,7 @@ export interface Translations {
// ── Sessions page ── // ── Sessions page ──
sessions: { sessions: {
title: string; title: string;
overview: string;
searchPlaceholder: string; searchPlaceholder: string;
noSessions: string; noSessions: string;
noMatch: string; noMatch: string;
@ -396,6 +397,8 @@ export interface Translations {
providersConfigured: string; providersConfigured: string;
replaceCurrentValue: string; replaceCurrentValue: string;
showAdvanced: string; showAdvanced: string;
showLess: string;
showMore: string;
showValue: string; showValue: string;
}; };

View File

@ -127,6 +127,7 @@ export const uk: Translations = {
sessions: { sessions: {
title: "Сесії", title: "Сесії",
overview: "Огляд",
searchPlaceholder: "Пошук у вмісті повідомлень...", searchPlaceholder: "Пошук у вмісті повідомлень...",
noSessions: "Поки немає сесій", noSessions: "Поки немає сесій",
noMatch: "Жодна сесія не відповідає вашому пошуку", noMatch: "Жодна сесія не відповідає вашому пошуку",
@ -269,7 +270,7 @@ export const uk: Translations = {
"Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).", "Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).",
identifierLabel: "Git URL або owner/repo", identifierLabel: "Git URL або owner/repo",
inactive: "неактивний", inactive: "неактивний",
installBtn: "Встановити з Git", installBtn: "Встановити",
installHeading: "Встановити з GitHub / Git URL", installHeading: "Встановити з GitHub / Git URL",
installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.", installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.",
memoryProviderLabel: "Постачальник пам'яті", memoryProviderLabel: "Постачальник пам'яті",
@ -367,6 +368,8 @@ export const uk: Translations = {
description: "Керуйте API-ключами та секретами, що зберігаються в", description: "Керуйте API-ключами та секретами, що зберігаються в",
hideAdvanced: "Сховати розширене", hideAdvanced: "Сховати розширене",
showAdvanced: "Показати розширене", showAdvanced: "Показати розширене",
showLess: "Показати менше",
showMore: "Показати більше",
llmProviders: "Постачальники LLM", llmProviders: "Постачальники LLM",
providersConfigured: "Налаштовано {configured} з {total} постачальників", providersConfigured: "Налаштовано {configured} з {total} постачальників",
getKey: "Отримати ключ", getKey: "Отримати ключ",
@ -392,7 +395,7 @@ export const uk: Translations = {
disconnect: "Відключити", disconnect: "Відключити",
managedExternally: "Керується ззовні", managedExternally: "Керується ззовні",
copied: "Скопійовано ✓", copied: "Скопійовано ✓",
cli: "CLI", cli: "Копіювати",
copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)", copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
connect: "Підключити", connect: "Підключити",
sessionExpires: "Сесія завершиться через {time}", sessionExpires: "Сесія завершиться через {time}",

View File

@ -127,6 +127,7 @@ export const zhHant: Translations = {
sessions: { sessions: {
title: "工作階段", title: "工作階段",
overview: "總覽",
searchPlaceholder: "搜尋訊息內容...", searchPlaceholder: "搜尋訊息內容...",
noSessions: "尚無工作階段", noSessions: "尚無工作階段",
noMatch: "沒有符合的工作階段", noMatch: "沒有符合的工作階段",
@ -269,7 +270,7 @@ export const zhHant: Translations = {
"探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI。", "探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI。",
identifierLabel: "Git 網址或 owner/repo", identifierLabel: "Git 網址或 owner/repo",
inactive: "未啟用", inactive: "未啟用",
installBtn: "從 Git 安裝", installBtn: "安裝",
installHeading: "從 GitHub / Git URL 安裝", installHeading: "從 GitHub / Git URL 安裝",
installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。", installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。",
memoryProviderLabel: "記憶提供者", memoryProviderLabel: "記憶提供者",
@ -367,6 +368,8 @@ export const zhHant: Translations = {
description: "管理儲存於下列位置的 API 金鑰與密鑰", description: "管理儲存於下列位置的 API 金鑰與密鑰",
hideAdvanced: "隱藏進階選項", hideAdvanced: "隱藏進階選項",
showAdvanced: "顯示進階選項", showAdvanced: "顯示進階選項",
showLess: "顯示較少",
showMore: "顯示更多",
llmProviders: "LLM 提供者", llmProviders: "LLM 提供者",
providersConfigured: "已設定 {configured}/{total} 個提供者", providersConfigured: "已設定 {configured}/{total} 個提供者",
getKey: "取得金鑰", getKey: "取得金鑰",
@ -392,7 +395,7 @@ export const zhHant: Translations = {
disconnect: "中斷連線", disconnect: "中斷連線",
managedExternally: "由外部管理", managedExternally: "由外部管理",
copied: "已複製 ✓", copied: "已複製 ✓",
cli: "CLI", cli: "複製",
copyCliCommand: "複製 CLI 指令(外部 / 備援用)", copyCliCommand: "複製 CLI 指令(外部 / 備援用)",
connect: "連線", connect: "連線",
sessionExpires: "工作階段將於 {time} 後過期", sessionExpires: "工作階段將於 {time} 後過期",

View File

@ -126,6 +126,7 @@ export const zh: Translations = {
sessions: { sessions: {
title: "会话", title: "会话",
overview: "概览",
searchPlaceholder: "搜索消息内容...", searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话", noSessions: "暂无会话",
noMatch: "没有匹配的会话", noMatch: "没有匹配的会话",
@ -265,7 +266,7 @@ export const zh: Translations = {
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI。", headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI。",
identifierLabel: "Git 地址或 owner/repo", identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用", inactive: "未启用",
installBtn: "从 Git 安装", installBtn: "安装",
installHeading: "从 GitHub / Git 地址安装", installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。", installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方", memoryProviderLabel: "记忆提供方",
@ -362,6 +363,8 @@ export const zh: Translations = {
description: "管理存储在以下位置的 API 密钥和凭据", description: "管理存储在以下位置的 API 密钥和凭据",
hideAdvanced: "隐藏高级选项", hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项", showAdvanced: "显示高级选项",
showLess: "显示更少",
showMore: "显示更多",
llmProviders: "LLM 提供商", llmProviders: "LLM 提供商",
providersConfigured: "已配置 {configured}/{total} 个提供商", providersConfigured: "已配置 {configured}/{total} 个提供商",
getKey: "获取密钥", getKey: "获取密钥",
@ -387,7 +390,7 @@ export const zh: Translations = {
disconnect: "断开连接", disconnect: "断开连接",
managedExternally: "外部管理", managedExternally: "外部管理",
copied: "已复制 ✓", copied: "已复制 ✓",
cli: "CLI", cli: "复制",
copyCliCommand: "复制 CLI 命令(用于外部/备用方式)", copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
connect: "连接", connect: "连接",
sessionExpires: "会话将在 {time} 后过期", sessionExpires: "会话将在 {time} 后过期",

View File

@ -146,7 +146,11 @@ code { font-size: 0.875rem; }
--color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base)); --color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base));
--color-secondary-foreground: var(--midground); --color-secondary-foreground: var(--midground);
--color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base)); --color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base));
--color-muted-foreground: color-mix(in srgb, var(--midground-base) 55%, transparent); /* Routes the shadcn `muted-foreground` slot through the DS semantic
text-secondary token (defaults to midground 80%) so legacy call
sites that use `text-muted-foreground` get a readable color
instead of the old 55%-transparent default. */
--color-muted-foreground: var(--color-text-secondary);
--color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base)); --color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base));
--color-accent-foreground: var(--midground); --color-accent-foreground: var(--midground);
--color-destructive: #fb2c36; --color-destructive: #fb2c36;

View File

@ -51,6 +51,11 @@ export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T>
return res.json(); return res.json();
} }
/** Encode a plugin registry key for URL paths (preserves `/` segment separators). */
function pluginPath(name: string): string {
return name.split("/").map(encodeURIComponent).join("/");
}
async function getSessionToken(): Promise<string> { async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken; if (_sessionToken) return _sessionToken;
const injected = window.__HERMES_SESSION_TOKEN__; const injected = window.__HERMES_SESSION_TOKEN__;
@ -293,25 +298,25 @@ export const api = {
enableAgentPlugin: (name: string) => enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`, `/api/dashboard/agent-plugins/${pluginPath(name)}/enable`,
{ method: "POST" }, { method: "POST" },
), ),
disableAgentPlugin: (name: string) => disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`, `/api/dashboard/agent-plugins/${pluginPath(name)}/disable`,
{ method: "POST" }, { method: "POST" },
), ),
updateAgentPlugin: (name: string) => updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>( fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`, `/api/dashboard/agent-plugins/${pluginPath(name)}/update`,
{ method: "POST" }, { method: "POST" },
), ),
removeAgentPlugin: (name: string) => removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>( fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`, `/api/dashboard/agent-plugins/${pluginPath(name)}`,
{ method: "DELETE" }, { method: "DELETE" },
), ),
@ -324,7 +329,7 @@ export const api = {
setPluginVisibility: (name: string, hidden: boolean) => setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>( fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`, `/api/dashboard/plugins/${pluginPath(name)}/visibility`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@ -5,6 +5,15 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
export const themedFont = "font-mondwest";
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
export const themedBody = "font-mondwest normal-case";
/** Mondwest brand chrome — uppercase section headers and nav labels. */
export const themedChrome = "font-mondwest text-display";
/** Relative time from a Unix epoch timestamp (seconds). */ /** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string { export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts; const delta = Date.now() / 1000 - ts;

View File

@ -119,7 +119,7 @@ function SortHeader({
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" /> <ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
) )
) : ( ) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/40 shrink-0" /> <ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
)} )}
</span> </span>
</th> </th>
@ -146,7 +146,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
{t.analytics.dailyTokenUsage} {t.analytics.dailyTokenUsage}
</CardTitle> </CardTitle>
</div> </div>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" /> <div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input} {t.analytics.input}
@ -177,7 +177,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
style={{ height: CHART_HEIGHT_PX }} style={{ height: CHART_HEIGHT_PX }}
> >
<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="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-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div> <div className="font-medium">{formatDate(d.day)}</div>
<div> <div>
{t.analytics.input}: {formatTokens(d.input_tokens)} {t.analytics.input}: {formatTokens(d.input_tokens)}
@ -207,7 +207,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
})} })}
</div> </div>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground"> <div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span> <span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && ( {daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span> <span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
@ -239,7 +239,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full font-mondwest normal-case text-sm">
<thead> <thead>
<tr className="border-b border-border text-muted-foreground text-xs"> <tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" /> <SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@ -298,7 +298,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full font-mondwest normal-case text-sm">
<thead> <thead>
<tr className="border-b border-border text-muted-foreground text-xs"> <tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" /> <SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@ -353,7 +353,7 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full font-mondwest normal-case text-sm">
<thead> <thead>
<tr className="border-b border-border text-muted-foreground text-xs"> <tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" /> <SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
@ -430,11 +430,23 @@ export default function AnalyticsPage() {
const periodLabel = const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle( setAfterTitle(
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
{loading && <Spinner className="shrink-0 text-base text-primary" />} <Badge tone="secondary" className="text-xs">
<Badge tone="secondary" className="text-[10px]">
{periodLabel} {periodLabel}
</Badge> </Badge>
{showTokens !== false && (
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>, </span>,
); );
setEnd( setEnd(
@ -453,16 +465,6 @@ export default function AnalyticsPage() {
</Button> </Button>
))} ))}
</div> </div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div> </div>
), ),
); );
@ -484,7 +486,7 @@ export default function AnalyticsPage() {
<Card> <Card>
<CardContent className="py-12"> <CardContent className="py-12">
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground"> <div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
<h2 className="font-display text-base tracking-wider uppercase text-foreground"> <h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
Token analytics hidden Token analytics hidden
</h2> </h2>
<p> <p>
@ -586,7 +588,7 @@ export default function AnalyticsPage() {
<div className="flex flex-col items-center text-muted-foreground"> <div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" /> <BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p> <p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60"> <p className="text-xs mt-1 text-text-tertiary">
{t.analytics.startSession} {t.analytics.startSession}
</p> </p>
</div> </div>

View File

@ -233,8 +233,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
aria-controls="chat-side-panel" aria-controls="chat-side-panel"
className={cn( className={cn(
"shrink-0 rounded border border-current/20", "shrink-0 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case", "px-2 py-1 text-xs font-medium tracking-wide",
"text-midground/80 hover:text-midground hover:bg-midground/5", "text-text-secondary hover:text-midground hover:bg-midground/5",
)} )}
> >
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
@ -708,9 +708,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// model badge, tool-call list, model picker. Best-effort: if the // model badge, tool-call list, model picker. Best-effort: if the
// sidecar fails to connect the terminal pane keeps working. // sidecar fails to connect the terminal pane keeps working.
// //
// `normal-case` opts out of the dashboard's global `uppercase` rule on
// the root `<div>` in App.tsx — terminal output must preserve case.
//
// Mobile model/tools sheet is portaled to `document.body` so it stacks // Mobile model/tools sheet is portaled to `document.body` so it stacks
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main // above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed` // dashboard column uses `relative z-2`, which traps `position:fixed`
@ -756,7 +753,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
)} )}
> >
<Typography <Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground" mondwest
className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }} style={{ mixBlendMode: "plus-lighter" }}
> >
{t.app.modelToolsSheetTitle} {t.app.modelToolsSheetTitle}
@ -769,7 +767,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
size="icon" size="icon"
onClick={closeMobilePanel} onClick={closeMobilePanel}
aria-label={t.app.closeModelTools} aria-label={t.app.closeModelTools}
className="text-midground/70 hover:text-midground" className="text-text-secondary hover:text-midground"
> >
<X /> <X />
</Button> </Button>
@ -789,7 +787,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
); );
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case"> <div className="flex min-h-0 flex-1 flex-col gap-2">
<PluginSlot name="chat:top" /> <PluginSlot name="chat:top" />
{mobileModelToolsPortal} {mobileModelToolsPortal}
@ -822,11 +820,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
aria-label="Copy last assistant response" aria-label="Copy last assistant response"
className={cn( className={cn(
"absolute z-10", "absolute z-10",
"normal-case tracking-normal font-normal",
"rounded border border-current/30", "rounded border border-current/30",
"bg-black/20 backdrop-blur-sm", "bg-black/20 backdrop-blur-sm",
"opacity-60 hover:opacity-100 hover:border-current/60", "opacity-70 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150 normal-case font-normal tracking-normal", "transition-opacity duration-150",
"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", "bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
"lg:bottom-4 lg:right-4", "lg:bottom-4 lg:right-4",
)} )}
style={{ color: TERMINAL_THEME.foreground }} style={{ color: TERMINAL_THEME.foreground }}

View File

@ -4,7 +4,6 @@ import {
Download, Download,
FormInput, FormInput,
RotateCcw, RotateCcw,
Save,
Search, Search,
Upload, Upload,
X, X,
@ -385,7 +384,7 @@ export default function ConfigPage() {
category={cat} category={cat}
className="h-4 w-4 text-muted-foreground" className="h-4 w-4 text-muted-foreground"
/> />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{prettyCategoryName(cat)} {prettyCategoryName(cat)}
</span> </span>
<div className="flex-1 border-t border-border" /> <div className="flex-1 border-t border-border" />
@ -393,7 +392,7 @@ export default function ConfigPage() {
)} )}
{showSection && ( {showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0"> <div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")} {section.replace(/_/g, " ")}
</span> </span>
<div className="flex-1 border-t border-border" /> <div className="flex-1 border-t border-border" />
@ -486,18 +485,18 @@ export default function ConfigPage() {
{yamlMode ? ( {yamlMode ? (
<Button <Button
size="sm" size="sm"
className="uppercase"
onClick={handleYamlSave} onClick={handleYamlSave}
disabled={yamlSaving} disabled={yamlSaving}
prefix={<Save />}
> >
{yamlSaving ? t.common.saving : t.common.save} {yamlSaving ? t.common.saving : t.common.save}
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
className="uppercase"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
prefix={<Save />}
> >
{saving ? t.common.saving : t.common.save} {saving ? t.common.saving : t.common.save}
</Button> </Button>
@ -534,13 +533,13 @@ export default function ConfigPage() {
<div className="sm:sticky sm:top-4"> <div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20"> <div className="flex flex-col border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border"> <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" /> <Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground"> <span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.config.filters} {t.config.filters}
</span> </span>
</div> </div>
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70"> <div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.config.sections} {t.config.sections}
</div> </div>
@ -556,7 +555,7 @@ export default function ConfigPage() {
setSearchQuery(""); setSearchQuery("");
setActiveCategory(cat); setActiveCategory(cat);
}} }}
className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]" className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
> >
<CategoryIcon <CategoryIcon
category={cat} category={cat}
@ -566,10 +565,10 @@ export default function ConfigPage() {
{prettyCategoryName(cat)} {prettyCategoryName(cat)}
</span> </span>
<span <span
className={`text-[10px] tabular-nums ${ className={`text-xs tabular-nums ${
isActive isActive
? "text-foreground/60" ? "text-text-secondary"
: "text-muted-foreground/50" : "text-text-tertiary"
}`} }`}
> >
{categoryCounts[cat] || 0} {categoryCounts[cat] || 0}
@ -591,7 +590,7 @@ export default function ConfigPage() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
{t.config.searchResults} {t.config.searchResults}
</CardTitle> </CardTitle>
<Badge tone="secondary" className="text-[10px]"> <Badge tone="secondary" className="text-xs">
{searchMatchedFields.length}{" "} {searchMatchedFields.length}{" "}
{t.config.fields.replace( {t.config.fields.replace(
"{s}", "{s}",
@ -622,7 +621,7 @@ export default function ConfigPage() {
/> />
{prettyCategoryName(activeCategory)} {prettyCategoryName(activeCategory)}
</CardTitle> </CardTitle>
<Badge tone="secondary" className="text-[10px]"> <Badge tone="secondary" className="text-xs">
{activeFields.length}{" "} {activeFields.length}{" "}
{t.config.fields.replace( {t.config.fields.replace(
"{s}", "{s}",

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react"; import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge"; import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader"; import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins"; import { PluginSlot } from "@/plugins";
import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string { function formatTime(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
@ -228,10 +229,10 @@ export default function CronPage() {
useLayoutEffect(() => { useLayoutEffect(() => {
setEnd( setEnd(
<Button <Button
className="uppercase"
size="sm" size="sm"
onClick={() => setCreateModalOpen(true)} onClick={() => setCreateModalOpen(true)}
> >
<Plus className="h-3 w-3" />
{t.common.create} {t.common.create}
</Button>, </Button>,
); );
@ -282,7 +283,7 @@ export default function CronPage() {
aria-modal="true" aria-modal="true"
aria-labelledby="create-cron-title" aria-labelledby="create-cron-title"
> >
<div className="relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col"> <div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
<Button <Button
ghost ghost
size="icon" size="icon"
@ -296,7 +297,7 @@ export default function CronPage() {
<header className="p-5 pb-3 border-b border-border"> <header className="p-5 pb-3 border-b border-border">
<h2 <h2
id="create-cron-title" id="create-cron-title"
className="font-display text-base tracking-wider uppercase" className="font-mondwest text-display text-base tracking-wider"
> >
{t.cron.newJob} {t.cron.newJob}
</h2> </h2>
@ -379,10 +380,11 @@ export default function CronPage() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
className="uppercase"
size="sm" size="sm"
onClick={handleCreate} onClick={handleCreate}
disabled={creating} disabled={creating}
prefix={creating ? <Spinner /> : <Plus />} prefix={creating ? <Spinner /> : undefined}
> >
{creating ? t.common.creating : t.common.create} {creating ? t.common.creating : t.common.create}
</Button> </Button>

View File

@ -133,12 +133,12 @@ function EnvVarRow({
// Compact inline row for unset, non-editing keys (used inside provider groups) // Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) { if (compact && !info.is_set && !isEditing) {
return ( return (
<div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden opacity-50 hover:opacity-100 transition-opacity"> <div className="flex items-center justify-between gap-3 py-1.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground"> <span className="font-mono-ui text-xs">
{varKey} {varKey}
</span> </span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block"> <span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description} {info.description}
</span> </span>
</div> </div>
@ -148,7 +148,7 @@ function EnvVarRow({
href={info.url} href={info.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
> >
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
@ -169,12 +169,12 @@ function EnvVarRow({
// Non-compact unset row // Non-compact unset row
if (!info.is_set && !isEditing) { if (!info.is_set && !isEditing) {
return ( return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden opacity-60 hover:opacity-100 transition-opacity"> <div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 min-w-0 overflow-hidden text-text-secondary hover:text-foreground transition-colors">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground"> <Label className="font-mono-ui text-xs">
{varKey} {varKey}
</Label> </Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block"> <span className="text-xs text-text-tertiary truncate hidden sm:block">
{info.description} {info.description}
</span> </span>
</div> </div>
@ -184,7 +184,7 @@ function EnvVarRow({
href={info.url} href={info.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
> >
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
@ -207,7 +207,7 @@ function EnvVarRow({
<div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden"> <div className="grid gap-2 border border-border p-4 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2 flex-wrap"> <div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label> <Label className="font-mono-ui text-xs">{varKey}</Label>
<Badge tone={info.is_set ? "success" : "outline"}> <Badge tone={info.is_set ? "success" : "outline"}>
{info.is_set ? t.common.set : t.env.notSet} {info.is_set ? t.common.set : t.env.notSet}
</Badge> </Badge>
@ -217,7 +217,7 @@ function EnvVarRow({
href={info.url} href={info.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
> >
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
@ -232,7 +232,7 @@ function EnvVarRow({
<Badge <Badge
key={tool} key={tool}
tone="secondary" tone="secondary"
className="text-[0.6rem] py-0 px-1.5" className="text-xs py-0 px-1.5"
> >
{tool} {tool}
</Badge> </Badge>
@ -396,7 +396,7 @@ function ProviderGroupCard({
{group.name === "Other" ? t.common.other : group.name} {group.name === "Other" ? t.common.other : group.name}
</span> </span>
{hasAnyConfigured && ( {hasAnyConfigured && (
<Badge tone="success" className="text-[0.6rem]"> <Badge tone="success" className="text-xs">
{configuredCount} {t.common.set.toLowerCase()} {configuredCount} {t.common.set.toLowerCase()}
</Badge> </Badge>
)} )}
@ -407,13 +407,13 @@ function ProviderGroupCard({
href={keyUrl} href={keyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" /> {t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}
<span className="text-[0.65rem] text-muted-foreground/60"> <span className="text-xs text-text-tertiary">
{t.env.keysCount {t.env.keysCount
.replace("{count}", String(group.entries.length)) .replace("{count}", String(group.entries.length))
.replace("{s}", group.entries.length !== 1 ? "s" : "")} .replace("{s}", group.entries.length !== 1 ? "s" : "")}
@ -546,7 +546,7 @@ export default function EnvPage() {
key={s.id} key={s.id}
type="button" type="button"
onClick={() => scrollTo(s.id)} onClick={() => scrollTo(s.id)}
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors" className="shrink-0 cursor-pointer px-2 py-0.5 font-mondwest text-display text-xs tracking-wider text-text-secondary hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
> >
{s.label} {s.label}
</button> </button>
@ -745,7 +745,7 @@ export default function EnvPage() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.env.description} <code>~/.hermes/.env</code> {t.env.description} <code>~/.hermes/.env</code>
</p> </p>
<p className="text-[0.7rem] text-muted-foreground/70"> <p className="text-xs text-text-tertiary">
{t.env.changesNote} {t.env.changesNote}
</p> </p>
</div> </div>
@ -797,80 +797,36 @@ export default function EnvPage() {
</CardContent> </CardContent>
</Card> </Card>
{nonProviderGrouped.map( {nonProviderGrouped.map((section) => {
({ if (section.totalEntries === 0) return null;
label,
icon: Icon,
setEntries,
unsetEntries,
totalEntries,
category,
}) => {
if (totalEntries === 0) return null;
return ( return (
<Card key={category} id={`section-${category}`}> <EnvCategoryCard
<CardHeader className="border-b border-border bg-card"> key={section.category}
<div className="flex items-center gap-2"> section={section}
<Icon className="h-5 w-5 text-muted-foreground" /> edits={edits}
<CardTitle className="text-base">{label}</CardTitle> setEdits={setEdits}
</div> revealed={revealed}
<CardDescription> saving={saving}
{setEntries.length} {t.common.of} {totalEntries}{" "} onSave={handleSave}
{t.common.configured} onClear={keyClear.requestDelete}
</CardDescription> onReveal={handleReveal}
</CardHeader> onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
<CardContent className="grid gap-3 pt-4 overflow-hidden"> />
{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>
);
},
)}
<PluginSlot name="env:bottom" /> <PluginSlot name="env:bottom" />
</div> </div>
); );
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* CollapsibleUnset — for non-provider categories */ /* EnvCategoryCard — keys / messaging / settings sections */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function CollapsibleUnset({ function EnvCategoryCard({
category: _category, section,
unsetEntries,
edits, edits,
setEdits, setEdits,
revealed, revealed,
@ -881,8 +837,14 @@ function CollapsibleUnset({
onCancelEdit, onCancelEdit,
clearDialogOpen = false, clearDialogOpen = false,
}: { }: {
category: string; section: {
unsetEntries: [string, EnvVarInfo][]; category: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
setEntries: [string, EnvVarInfo][];
totalEntries: number;
unsetEntries: [string, EnvVarInfo][];
};
edits: Record<string, string>; edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>; setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>; revealed: Record<string, string>;
@ -893,39 +855,64 @@ function CollapsibleUnset({
onCancelEdit: (key: string) => void; onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean; clearDialogOpen?: boolean;
}) { }) {
const [collapsed, setCollapsed] = useState(true); const noneConfigured = section.setEntries.length === 0;
const [showAll, setShowAll] = useState(noneConfigured);
const { t } = useI18n(); const { t } = useI18n();
const Icon = section.icon;
const hasContent = section.setEntries.length > 0 || showAll;
const rowProps = {
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen,
};
return ( return (
<> <Card id={`section-${section.category}`}>
<Button <CardHeader
ghost className={`bg-card${hasContent ? " border-b border-border" : ""}`}
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"
> >
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))} <div className="flex items-center justify-between gap-3">
</Button> <div className="flex min-w-0 items-center gap-2">
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-base">{section.label}</CardTitle>
</div>
{!collapsed && {section.unsetEntries.length > 0 && (
unsetEntries.map(([key, info]) => ( <button
<EnvVarRow type="button"
key={key} onClick={() => setShowAll((open) => !open)}
varKey={key} aria-expanded={showAll}
info={info} className="shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mondwest text-xs tracking-[0.08em] text-text-secondary transition-colors hover:text-foreground"
edits={edits} >
setEdits={setEdits} {showAll ? t.env.showLess : t.env.showMore}
revealed={revealed} </button>
saving={saving} )}
onSave={onSave} </div>
onClear={onClear}
onReveal={onReveal} <CardDescription>
onCancelEdit={onCancelEdit} {section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
clearDialogOpen={clearDialogOpen} {t.common.configured}
/> </CardDescription>
))} </CardHeader>
</>
{hasContent && (
<CardContent className="grid gap-3 overflow-hidden pt-4">
{section.setEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
{showAll &&
section.unsetEntries.map(([key, info]) => (
<EnvVarRow key={key} varKey={key} info={info} {...rowProps} />
))}
</CardContent>
)}
</Card>
); );
} }

View File

@ -40,11 +40,13 @@ const LINE_COLORS: Record<string, string> = {
error: "text-destructive", error: "text-destructive",
warning: "text-warning", warning: "text-warning",
info: "text-foreground", info: "text-foreground",
debug: "text-muted-foreground/60", debug: "text-text-tertiary",
}; };
const toOptions = <T extends string>(values: readonly T[]) => const formatFilterLabel = (value: string) => value.toUpperCase();
values.map((v) => ({ value: v, label: v }));
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
const filterGroupClass = const filterGroupClass =
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center"; "flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
@ -85,41 +87,42 @@ export default function LogsPage() {
useLayoutEffect(() => { useLayoutEffect(() => {
setAfterTitle( setAfterTitle(
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
{loading && <Spinner className="shrink-0 text-base text-primary" />} <Badge tone="secondary" className="text-xs">
<Badge tone="secondary" className="text-[10px]"> {formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
{file} · {level} · {component} {formatFilterLabel(component)}
</Badge> </Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={fetchLogs}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>, </span>,
); );
setEnd( setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3"> <div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
<Switch <Switch
checked={autoRefresh} checked={autoRefresh}
onCheckedChange={setAutoRefresh} onCheckedChange={setAutoRefresh}
id="logs-auto-refresh" id="logs-auto-refresh"
/> />
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
{autoRefresh && ( {autoRefresh && (
<Badge tone="success" className="text-[10px]"> <Badge tone="success" className="text-xs">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" /> <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live} {t.common.live}
</Badge> </Badge>
)} )}
</div> </div>
<Button
type="button"
size="sm"
outlined
onClick={fetchLogs}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>, </div>,
); );
return () => { return () => {
@ -163,7 +166,7 @@ export default function LogsPage() {
className={segmentedClass} className={segmentedClass}
value={file} value={file}
onChange={setFile} onChange={setFile}
options={toOptions(FILES)} options={toSegmentOptions(FILES)}
/> />
</FilterGroup> </FilterGroup>
@ -172,7 +175,7 @@ export default function LogsPage() {
className={segmentedClass} className={segmentedClass}
value={level} value={level}
onChange={setLevel} onChange={setLevel}
options={toOptions(LEVELS)} options={toSegmentOptions(LEVELS)}
/> />
</FilterGroup> </FilterGroup>
@ -181,7 +184,7 @@ export default function LogsPage() {
className={segmentedClass} className={segmentedClass}
value={component} value={component}
onChange={setComponent} onChange={setComponent}
options={toOptions(COMPONENTS)} options={toSegmentOptions(COMPONENTS)}
/> />
</FilterGroup> </FilterGroup>

View File

@ -19,7 +19,7 @@ import type {
ModelsAnalyticsModelEntry, ModelsAnalyticsModelEntry,
ModelsAnalyticsResponse, ModelsAnalyticsResponse,
} from "@/lib/api"; } from "@/lib/api";
import { timeAgo } from "@/lib/utils"; import { timeAgo, cn, themedBody } from "@/lib/utils";
import { formatTokenCount } from "@/lib/format"; import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Spinner } from "@nous-research/ui/ui/components/spinner";
@ -125,7 +125,7 @@ function TokenBar({
</div> </div>
{/* Legend */} {/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground"> <div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => ( {segments.map((s, i) => (
<span key={i} className="flex items-center gap-1"> <span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} /> <span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
@ -152,22 +152,22 @@ function CapabilityBadges({
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && ( {capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"> <span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools <Wrench className="h-2.5 w-2.5" /> Tools
</span> </span>
)} )}
{capabilities.supports_vision && ( {capabilities.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> <span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision <Eye className="h-2.5 w-2.5" /> Vision
</span> </span>
)} )}
{capabilities.supports_reasoning && ( {capabilities.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400"> <span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning <Brain className="h-2.5 w-2.5" /> Reasoning
</span> </span>
)} )}
{capabilities.model_family && ( {capabilities.model_family && (
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> <span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-xs font-medium text-text-secondary">
{capabilities.model_family} {capabilities.model_family}
</span> </span>
)} )}
@ -237,7 +237,7 @@ function UseAsMenu({
outlined outlined
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
disabled={busy} disabled={busy}
className="text-[10px] h-6 px-2" className="h-6 px-2 text-xs uppercase"
prefix={busy ? <Spinner /> : null} prefix={busy ? <Spinner /> : null}
> >
Use as <ChevronDown className="h-3 w-3" /> Use as <ChevronDown className="h-3 w-3" />
@ -248,20 +248,20 @@ function UseAsMenu({
type="button" type="button"
onClick={() => assign("main", "")} onClick={() => assign("main", "")}
disabled={busy} disabled={busy}
className="flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/50 disabled:opacity-40" className="flex w-full items-center justify-between px-3 py-2 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Star className="h-3 w-3" /> <Star className="h-3 w-3" />
Main model Main model
</span> </span>
{isMain && ( {isMain && (
<span className="text-[9px] uppercase tracking-wider text-primary/80"> <span className="text-display text-xs tracking-wider text-primary">
current current
</span> </span>
)} )}
</button> </button>
<div className="border-t border-border/50 px-3 py-1.5 text-[9px] uppercase tracking-wider text-muted-foreground"> <div className="border-t border-border/50 px-3 py-1.5 text-display text-xs tracking-wider text-text-tertiary">
Auxiliary task Auxiliary task
</div> </div>
@ -269,7 +269,7 @@ function UseAsMenu({
type="button" type="button"
onClick={() => assign("auxiliary", "")} onClick={() => assign("auxiliary", "")}
disabled={busy} disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40" className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
> >
<span>All auxiliary tasks</span> <span>All auxiliary tasks</span>
</button> </button>
@ -280,11 +280,11 @@ function UseAsMenu({
type="button" type="button"
onClick={() => assign("auxiliary", t.key)} onClick={() => assign("auxiliary", t.key)}
disabled={busy} disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40" className="flex w-full items-center justify-between px-3 py-1.5 text-xs uppercase hover:bg-muted/50 disabled:opacity-40"
> >
<span>{t.label}</span> <span>{t.label}</span>
{mainAuxTask === t.key && ( {mainAuxTask === t.key && (
<span className="text-[9px] uppercase tracking-wider text-primary/80"> <span className="text-display text-xs tracking-wider text-primary">
current current
</span> </span>
)} )}
@ -292,7 +292,7 @@ function UseAsMenu({
))} ))}
{error && ( {error && (
<div className="px-3 py-2 text-[10px] text-destructive border-t border-border/50"> <div className="px-3 py-2 text-xs text-destructive border-t border-border/50">
{error} {error}
</div> </div>
)} )}
@ -345,36 +345,36 @@ function ModelCard({
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground/50 text-xs font-mono"> <span className="text-text-tertiary text-xs font-mono">
#{rank} #{rank}
</span> </span>
<CardTitle className="text-sm font-mono-ui truncate"> <CardTitle className="text-sm font-mono-ui truncate">
{shortModelName(entry.model)} {shortModelName(entry.model)}
</CardTitle> </CardTitle>
{isMain && ( {isMain && (
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-primary"> <span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-primary">
<Star className="h-2.5 w-2.5" /> main <Star className="h-2.5 w-2.5" /> main
</span> </span>
)} )}
{mainAuxTask && ( {mainAuxTask && (
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-purple-600 dark:text-purple-400"> <span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-display text-xs font-medium tracking-wider text-purple-600 dark:text-purple-400">
aux · {mainAuxTask} aux · {mainAuxTask}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
{provider && ( {provider && (
<Badge tone="secondary" className="text-[9px]"> <Badge tone="secondary" className="text-xs">
{provider} {provider}
</Badge> </Badge>
)} )}
{caps.context_window && caps.context_window > 0 && ( {caps.context_window && caps.context_window > 0 && (
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-text-secondary">
{formatTokenCount(caps.context_window)} ctx {formatTokenCount(caps.context_window)} ctx
</span> </span>
)} )}
{caps.max_output_tokens && caps.max_output_tokens > 0 && ( {caps.max_output_tokens && caps.max_output_tokens > 0 && (
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-text-secondary">
{formatTokenCount(caps.max_output_tokens)} out {formatTokenCount(caps.max_output_tokens)} out
</span> </span>
)} )}
@ -386,7 +386,7 @@ function ModelCard({
<div className="text-xs font-mono font-semibold"> <div className="text-xs font-mono font-semibold">
{formatTokens(totalTokens)} {formatTokens(totalTokens)}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-text-tertiary">
{t.models.tokens} {t.models.tokens}
</div> </div>
</div> </div>
@ -396,7 +396,7 @@ function ModelCard({
<div className="text-xs font-mono font-semibold"> <div className="text-xs font-mono font-semibold">
{entry.sessions} {entry.sessions}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-text-tertiary">
{t.models.sessions} {t.models.sessions}
</div> </div>
</div> </div>
@ -425,7 +425,7 @@ function ModelCard({
<div className="grid grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center"> <div className="text-center">
<div className="font-mono font-semibold">{entry.sessions}</div> <div className="font-mono font-semibold">{entry.sessions}</div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-text-tertiary">
{t.models.sessions} {t.models.sessions}
</div> </div>
</div> </div>
@ -433,7 +433,7 @@ function ModelCard({
<div className="font-mono font-semibold"> <div className="font-mono font-semibold">
{formatTokens(entry.avg_tokens_per_session)} {formatTokens(entry.avg_tokens_per_session)}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-text-tertiary">
{t.models.avgPerSession} {t.models.avgPerSession}
</div> </div>
</div> </div>
@ -441,7 +441,7 @@ function ModelCard({
<div className="font-mono font-semibold"> <div className="font-mono font-semibold">
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"} {entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-xs text-text-tertiary">
{t.models.apiCalls} {t.models.apiCalls}
</div> </div>
</div> </div>
@ -449,7 +449,7 @@ function ModelCard({
</> </>
)} )}
<div className="flex items-center justify-between text-[10px] text-muted-foreground border-t border-border/30 pt-2"> <div className="flex items-center justify-between text-xs text-text-secondary border-t border-border/30 pt-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{showTokens && entry.estimated_cost > 0 && ( {showTokens && entry.estimated_cost > 0 && (
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
@ -524,7 +524,7 @@ function AuxiliaryTasksModal({
aria-modal="true" aria-modal="true"
aria-labelledby="aux-modal-title" aria-labelledby="aux-modal-title"
> >
<div className="relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col"> <div className={cn(themedBody, "relative w-full max-w-2xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button <Button
ghost ghost
size="icon" size="icon"
@ -539,7 +539,7 @@ function AuxiliaryTasksModal({
<div className="flex items-center justify-between gap-3 pr-8"> <div className="flex items-center justify-between gap-3 pr-8">
<h2 <h2
id="aux-modal-title" id="aux-modal-title"
className="font-display text-base tracking-wider uppercase" className="font-mondwest text-display text-base tracking-wider"
> >
Auxiliary Tasks Auxiliary Tasks
</h2> </h2>
@ -548,13 +548,13 @@ function AuxiliaryTasksModal({
outlined outlined
onClick={() => setConfirmReset(true)} onClick={() => setConfirmReset(true)}
disabled={resetBusy} disabled={resetBusy}
className="text-[10px] h-6" className="h-6 text-xs uppercase"
prefix={resetBusy ? <Spinner /> : null} prefix={resetBusy ? <Spinner /> : null}
> >
Reset all to auto Reset all to auto
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground/80 mt-2"> <p className="text-xs text-text-secondary mt-2">
Auxiliary tasks handle side-jobs like vision, session search, and Auxiliary tasks handle side-jobs like vision, session search, and
compression. <span className="font-mono">auto</span> means compression. <span className="font-mono">auto</span> means
&quot;use the main model&quot;. Override per-task when you want a &quot;use the main model&quot;. Override per-task when you want a
@ -575,11 +575,11 @@ function AuxiliaryTasksModal({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-xs font-medium">{t.label}</span> <span className="text-xs font-medium">{t.label}</span>
<span className="text-[10px] text-muted-foreground/60"> <span className="text-xs text-text-tertiary">
{t.hint} {t.hint}
</span> </span>
</div> </div>
<div className="text-[10px] font-mono text-muted-foreground truncate"> <div className="text-xs font-mono text-text-secondary truncate">
{isAuto {isAuto
? "auto (use main model)" ? "auto (use main model)"
: `${cur?.provider} · ${cur?.model || "(provider default)"}`} : `${cur?.provider} · ${cur?.model || "(provider default)"}`}
@ -589,7 +589,7 @@ function AuxiliaryTasksModal({
size="sm" size="sm"
outlined outlined
onClick={() => setPicker({ kind: "aux", task: t.key })} onClick={() => setPicker({ kind: "aux", task: t.key })}
className="text-[10px] h-6" className="h-6 text-xs uppercase"
> >
Change Change
</Button> </Button>
@ -675,7 +675,7 @@ function ModelSettingsPanel({
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" /> <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle> <CardTitle className="text-sm">Model Settings</CardTitle>
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]"> <span className="max-w-full min-w-0 text-xs text-text-secondary [overflow-wrap:anywhere]">
applies to new sessions applies to new sessions
</span> </span>
</div> </div>
@ -687,11 +687,11 @@ function ModelSettingsPanel({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<Star className="h-3 w-3 text-primary" /> <Star className="h-3 w-3 text-primary" />
<span className="text-xs font-medium uppercase tracking-wider"> <span className="text-display text-xs font-medium tracking-wider">
Main model Main model
</span> </span>
</div> </div>
<div className="text-xs font-mono text-muted-foreground truncate"> <div className="text-xs font-mono text-text-secondary truncate">
{mainProv || "(unset)"} {mainProv || "(unset)"}
{mainProv && mainModel && " · "} {mainProv && mainModel && " · "}
{mainModel || "(unset)"} {mainModel || "(unset)"}
@ -700,7 +700,7 @@ function ModelSettingsPanel({
<Button <Button
size="sm" size="sm"
onClick={() => setPicker({ kind: "main" })} onClick={() => setPicker({ kind: "main" })}
className="shrink-0 self-start text-xs sm:self-center" className="shrink-0 self-start text-xs uppercase sm:self-center"
> >
Change Change
</Button> </Button>
@ -710,12 +710,12 @@ function ModelSettingsPanel({
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3"> <div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<Cpu className="h-3 w-3 text-muted-foreground" /> <Cpu className="h-3 w-3 text-text-tertiary" />
<span className="text-xs font-medium uppercase tracking-wider"> <span className="text-display text-xs font-medium tracking-wider">
Auxiliary tasks Auxiliary tasks
</span> </span>
</div> </div>
<div className="text-xs font-mono text-muted-foreground truncate"> <div className="text-xs font-mono text-text-secondary truncate">
{auxOverrideCount > 0 {auxOverrideCount > 0
? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto` ? `${auxOverrideCount} override${auxOverrideCount > 1 ? "s" : ""} · ${AUX_TASKS.length - auxOverrideCount} auto`
: `${AUX_TASKS.length} tasks · all auto`} : `${AUX_TASKS.length} tasks · all auto`}
@ -725,7 +725,7 @@ function ModelSettingsPanel({
size="sm" size="sm"
outlined outlined
onClick={() => setAuxModalOpen(true)} onClick={() => setAuxModalOpen(true)}
className="shrink-0 self-start text-xs sm:self-center" className="shrink-0 self-start text-xs uppercase sm:self-center"
> >
Configure Configure
</Button> </Button>
@ -821,11 +821,21 @@ export default function ModelsPage() {
const periodLabel = const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle( setAfterTitle(
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
{loading && <Spinner className="shrink-0 text-base text-primary" />} <Badge tone="secondary" className="text-xs">
<Badge tone="secondary" className="text-[10px]">
{periodLabel} {periodLabel}
</Badge> </Badge>
<Button
type="button"
ghost
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={load}
disabled={loading}
aria-label={t.common.refresh}
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>, </span>,
); );
setEnd( setEnd(
@ -838,21 +848,12 @@ export default function ModelsPage() {
size="sm" size="sm"
outlined={days !== p.days} outlined={days !== p.days}
onClick={() => setDays(p.days)} onClick={() => setDays(p.days)}
className="uppercase"
> >
{p.label} {p.label}
</Button> </Button>
))} ))}
</div> </div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>, </div>,
); );
return () => { return () => {
@ -926,7 +927,7 @@ export default function ModelsPage() {
/> />
</div> </div>
{!showTokens && ( {!showTokens && (
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed"> <p className="mt-4 text-xs text-text-tertiary leading-relaxed">
Token & cost analytics are hidden because the local counts Token & cost analytics are hidden because the local counts
exclude auxiliary calls (compression, vision, web extract, exclude auxiliary calls (compression, vision, web extract,
) and provider retries, so they diverge from your provider ) and provider retries, so they diverge from your provider
@ -977,7 +978,7 @@ export default function ModelsPage() {
<div className="flex flex-col items-center text-muted-foreground"> <div className="flex flex-col items-center text-muted-foreground">
<Cpu className="h-8 w-8 mb-3 opacity-40" /> <Cpu className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.models.noModelsData}</p> <p className="text-sm font-medium">{t.models.noModelsData}</p>
<p className="text-xs mt-1 text-muted-foreground/60"> <p className="text-xs mt-1 text-text-tertiary">
{t.models.startSession} {t.models.startSession}
</p> </p>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react"; import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types"; import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -39,7 +39,7 @@ export default function PluginsPage() {
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { setEnd } = usePageHeader(); const { setAfterTitle } = usePageHeader();
const loadHub = useCallback(() => { const loadHub = useCallback(() => {
return api return api
@ -59,22 +59,20 @@ export default function PluginsPage() {
}, [loadHub]); }, [loadHub]);
useEffect(() => { useEffect(() => {
setEnd( setAfterTitle(
<div className="flex w-full min-w-0 justify-start sm:justify-end"> <Button
<Button ghost
ghost size="icon"
size="sm" className="shrink-0 text-muted-foreground hover:text-foreground"
className="w-max max-w-full shrink-0 gap-2" disabled={loading || rescanBusy}
disabled={loading || rescanBusy} onClick={() => void onRescan()}
onClick={() => void onRescan()} aria-label={t.pluginsPage.refreshDashboard}
> >
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />} {rescanBusy ? <Spinner /> : <RefreshCw />}
{t.pluginsPage.refreshDashboard} </Button>,
</Button>
</div>,
); );
return () => setEnd(null); return () => setAfterTitle(null);
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]); }, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
const onInstall = async () => { const onInstall = async () => {
const id = installId.trim(); const id = installId.trim();
@ -160,7 +158,7 @@ export default function PluginsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle> <CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case"> <p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.providersHint} {t.pluginsPage.providersHint}
</p> </p>
</CardHeader> </CardHeader>
@ -212,13 +210,13 @@ export default function PluginsPage() {
</div> </div>
<Button <Button
className="w-fit gap-2" className="w-fit uppercase"
size="sm" size="sm"
disabled={providerBusy} disabled={providerBusy}
onClick={() => void onSaveProviders()} onClick={() => void onSaveProviders()}
prefix={providerBusy ? <Spinner /> : undefined}
> >
{providerBusy ? <Spinner /> : null} {t.common.save}
{t.pluginsPage.saveProviders}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -227,7 +225,7 @@ export default function PluginsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle> <CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case"> <p className="text-xs tracking-[0.08em] text-text-tertiary">
{t.pluginsPage.installHint} {t.pluginsPage.installHint}
</p> </p>
</CardHeader> </CardHeader>
@ -240,7 +238,7 @@ export default function PluginsPage() {
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label> <Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input <Input
className="normal-case font-sans lowercase" className="font-mono-ui lowercase"
id="install-url" id="install-url"
placeholder="owner/repo or https://..." placeholder="owner/repo or https://..."
spellCheck={false} spellCheck={false}
@ -256,7 +254,7 @@ export default function PluginsPage() {
<Switch checked={installForce} onCheckedChange={setInstallForce} /> <Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case"> <span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.forceReinstall} {t.pluginsPage.forceReinstall}
</span> </span>
</div> </div>
@ -265,27 +263,27 @@ export default function PluginsPage() {
<Switch checked={installEnable} onCheckedChange={setInstallEnable} /> <Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case"> <span className="text-xs tracking-[0.06em] text-text-secondary">
{t.pluginsPage.enableAfterInstall} {t.pluginsPage.enableAfterInstall}
</span> </span>
</div> </div>
</div> </div>
<Button <Button
className="w-fit gap-2" className="w-fit uppercase"
size="sm" size="sm"
disabled={installBusy} disabled={installBusy}
onClick={() => void onInstall()} onClick={() => void onInstall()}
prefix={installBusy ? <Spinner /> : undefined}
> >
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
{t.pluginsPage.installBtn} {t.pluginsPage.installBtn}
</Button> </Button>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case"> <p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.rescanHint} {t.pluginsPage.rescanHint}
</p> </p>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case"> <p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.removeHint} {t.pluginsPage.removeHint}
</p> </p>
</CardContent> </CardContent>
@ -293,20 +291,20 @@ export default function PluginsPage() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85"> <h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.pluginListHeading} {t.pluginsPage.pluginListHeading}
</h3> </h3>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65"> <div className="flex items-center gap-2 py-8 text-xs text-text-tertiary">
<Spinner /> <Spinner />
<span>{t.common.loading}</span> <span>{t.common.loading}</span>
</div> </div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p> <p className="text-xs text-text-tertiary">{t.common.noResults}</p>
) : ( ) : (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
@ -331,7 +329,7 @@ export default function PluginsPage() {
<div className="flex flex-col gap-3 opacity-95"> <div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85"> <h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.orphanHeading} {t.pluginsPage.orphanHeading}
</h3> </h3>
@ -339,7 +337,7 @@ export default function PluginsPage() {
{hub!.orphan_dashboard_plugins.map((m) => ( {hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}> <li className="text-xs text-text-secondary" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path} {m.label ?? m.name} {m.description || m.tab?.path}
@ -433,36 +431,35 @@ function PluginRowCard(props: PluginRowCardProps) {
</div> </div>
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{row.runtime_status === "enabled" ? (
<Button
<Button disabled={busy}
disabled={busy || row.runtime_status === "enabled"} ghost
ghost size="sm"
size="sm" onClick={() => {
onClick={() => { void setRuntimeLoading(row.name, async () => {
void setRuntimeLoading(row.name, async () => { await api.disableAgentPlugin(row.name);
await api.enableAgentPlugin(row.name); showToast(t.pluginsPage.disableRuntime, "success");
showToast(t.pluginsPage.enableRuntime, "success"); });
}); }}
}} >
> {t.pluginsPage.disableRuntime}
{t.pluginsPage.enableRuntime} </Button>
</Button> ) : (
<Button
disabled={busy}
<Button ghost
disabled={busy || row.runtime_status === "disabled"} size="sm"
ghost onClick={() => {
size="sm" void setRuntimeLoading(row.name, async () => {
onClick={() => { await api.enableAgentPlugin(row.name);
void setRuntimeLoading(row.name, async () => { showToast(t.pluginsPage.enableRuntime, "success");
await api.disableAgentPlugin(row.name); });
showToast(t.pluginsPage.disableRuntime, "success"); }}
}); >
}} {t.pluginsPage.enableRuntime}
> </Button>
{t.pluginsPage.disableRuntime} )}
</Button>
{tabPath ? ( {tabPath ? (
@ -470,7 +467,7 @@ function PluginRowCard(props: PluginRowCardProps) {
className={cn( className={cn(
"inline-flex items-center rounded-none px-3 py-1.5", "inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10", "border border-current/25 hover:bg-current/10",
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase", "font-mondwest text-display text-xs tracking-[0.1em]",
)} )}
to={tabPath} to={tabPath}
> >
@ -535,14 +532,14 @@ function PluginRowCard(props: PluginRowCardProps) {
</div> </div>
{row.description ? ( {row.description ? (
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words"> <p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
{row.description} {row.description}
</p> </p>
) : null} ) : null}
{dm?.slots?.length ? ( {dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case"> <p className="text-xs tracking-[0.05em] text-text-tertiary">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")} {t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p> </p>
) : null} ) : null}
@ -557,7 +554,7 @@ function PluginRowCard(props: PluginRowCardProps) {
{!row.has_dashboard_manifest && !dm ? ( {!row.has_dashboard_manifest && !dm ? (
<p className="text-[0.65rem] italic text-midforeground/45 normal-case"> <p className="text-xs italic text-text-disabled">
{t.pluginsPage.noDashboardTab} {t.pluginsPage.noDashboardTab}
</p> </p>
) : null} ) : null}

View File

@ -8,7 +8,6 @@ import {
import { import {
ChevronDown, ChevronDown,
Pencil, Pencil,
Plus,
Terminal, Terminal,
Trash2, Trash2,
Users, Users,
@ -31,6 +30,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader"; import { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously // Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST. // invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
@ -231,8 +231,11 @@ export default function ProfilesPage() {
// Put "Create" button in page header // Put "Create" button in page header
useLayoutEffect(() => { useLayoutEffect(() => {
setEnd( setEnd(
<Button size="sm" onClick={() => setCreateModalOpen(true)}> <Button
<Plus className="h-3 w-3" /> className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create} {t.common.create}
</Button>, </Button>,
); );
@ -256,10 +259,7 @@ export default function ProfilesPage() {
} }
return ( return (
// Profile names, model slugs, and paths are case-sensitive; opt out of <div className="flex flex-col gap-6">
// the app shell's global ``uppercase`` so they render as the user typed.
// Children that explicitly opt back in (Badges, etc.) keep their casing.
<div className="flex flex-col gap-6 normal-case">
<Toast toast={toast} /> <Toast toast={toast} />
<DeleteConfirmDialog <DeleteConfirmDialog
@ -287,7 +287,7 @@ export default function ProfilesPage() {
aria-modal="true" aria-modal="true"
aria-labelledby="create-profile-title" aria-labelledby="create-profile-title"
> >
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col"> <div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col")}>
<Button <Button
ghost ghost
size="icon" size="icon"
@ -301,7 +301,7 @@ export default function ProfilesPage() {
<header className="p-5 pb-3 border-b border-border"> <header className="p-5 pb-3 border-b border-border">
<h2 <h2
id="create-profile-title" id="create-profile-title"
className="font-display text-base tracking-wider uppercase" className="font-mondwest text-display text-base tracking-wider"
> >
{t.profiles.newProfile} {t.profiles.newProfile}
</h2> </h2>
@ -339,7 +339,7 @@ export default function ProfilesPage() {
/> />
<Label <Label
className="font-sans normal-case tracking-normal text-sm cursor-pointer" className="font-mondwest normal-case tracking-normal text-sm cursor-pointer"
htmlFor="clone-from-default" htmlFor="clone-from-default"
> >
{t.profiles.cloneFromDefault} {t.profiles.cloneFromDefault}
@ -347,8 +347,12 @@ export default function ProfilesPage() {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={handleCreate} disabled={creating}> <Button
<Plus className="h-3 w-3" /> className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
>
{creating ? t.common.creating : t.common.create} {creating ? t.common.creating : t.common.create}
</Button> </Button>
</div> </div>
@ -523,7 +527,7 @@ export default function ProfilesPage() {
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2"> <div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
<Label <Label
htmlFor={`soul-editor-${p.name}`} htmlFor={`soul-editor-${p.name}`}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground" className="flex items-center gap-2 font-mondwest text-display text-xs tracking-wider text-muted-foreground"
> >
{t.profiles.soulSection} {t.profiles.soulSection}
</Label> </Label>
@ -537,10 +541,11 @@ export default function ProfilesPage() {
<div> <div>
<Button <Button
size="sm" size="sm"
className="uppercase"
onClick={() => handleSaveSoul(p.name)} onClick={() => handleSaveSoul(p.name)}
disabled={soulSaving} disabled={soulSaving}
> >
{soulSaving ? t.common.saving : t.profiles.saveSoul} {soulSaving ? t.common.saving : t.common.save}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -37,6 +37,7 @@ import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast"; import { Toast } from "@/components/Toast";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Badge } from "@nous-research/ui/ui/components/badge"; import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -83,7 +84,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last)); parts.push(snippet.slice(last));
} }
return ( return (
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80"> <p className="font-mondwest normal-case mt-0.5 min-w-0 max-w-full truncate text-xs text-text-secondary">
{parts} {parts}
</p> </p>
); );
@ -191,12 +192,12 @@ function MessageBubble({
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span> <span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && ( {isHit && (
<Badge tone="warning" className="text-[9px] py-0 px-1.5"> <Badge tone="warning" className="text-xs py-0 px-1.5">
{t.common.match} {t.common.match}
</Badge> </Badge>
)} )}
{msg.timestamp && ( {msg.timestamp && (
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-text-tertiary">
{timeAgo(msg.timestamp)} {timeAgo(msg.timestamp)}
</span> </span>
)} )}
@ -294,6 +295,43 @@ function SessionRow({
const SourceIcon = sourceInfo.icon; const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled"; const hasTitle = session.title && session.title !== "Untitled";
const actionButtons = (
<>
<Badge tone="outline" className="text-xs">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</>
);
return ( return (
<div <div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${ className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
@ -310,76 +348,54 @@ function SessionRow({
<SourceIcon className="h-4 w-4" /> <SourceIcon className="h-4 w-4" />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span <div className="flex min-w-0 items-center gap-2">
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`} <span
> className={`font-mondwest normal-case min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
{hasTitle >
? session.title {hasTitle
: session.preview ? session.title
? session.preview.slice(0, 60) : session.preview
: t.sessions.untitledSession} ? session.preview.slice(0, 60)
</span> : t.sessions.untitledSession}
{session.is_active && ( </span>
<Badge tone="success" className="shrink-0 text-[10px]"> {session.is_active && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" /> <Badge tone="success" className="shrink-0 text-xs">
{t.common.live} <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
</Badge> {t.common.live}
)} </Badge>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()}
</span>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div> </div>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]"> <div className="hidden shrink-0 items-center gap-2 sm:flex">
{(session.model ?? t.common.unknown).split("/").pop()} {actionButtons}
</span>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div> </div>
</div> </div>
{snippet && <SnippetHighlight snippet={snippet} />}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2 sm:hidden">
<Badge tone="outline" className="text-[10px]"> {actionButtons}
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</div> </div>
</div> </div>
</div> </div>
@ -408,11 +424,62 @@ function SessionRow({
); );
} }
type SessionsView = "list" | "overview";
const PAGE_SIZE = 20;
function SessionsPagination({
className,
compact = false,
onPageChange,
page,
total,
}: SessionsPaginationProps) {
const { t } = useI18n();
const pageCount = Math.ceil(total / PAGE_SIZE);
return (
<div
className={`flex items-center ${compact ? "gap-1" : "justify-between pt-2"}${className ? ` ${className}` : ""}`}
>
{!compact && (
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
{t.common.of} {total}
</span>
)}
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="px-2 text-xs text-muted-foreground">
{t.common.page} {page + 1} {t.common.of} {pageCount}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => onPageChange(page + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
);
}
export default function SessionsPage() { export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const PAGE_SIZE = 20;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@ -424,16 +491,16 @@ export default function SessionsPage() {
const logScrollRef = useRef<HTMLPreElement | null>(null); const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null); const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]); const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader(); const { setAfterTitle } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions(); const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled(); const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
useLayoutEffect(() => { useLayoutEffect(() => {
if (loading) { if (loading) {
setAfterTitle(null); setAfterTitle(null);
setEnd(null);
return; return;
} }
setAfterTitle( setAfterTitle(
@ -441,46 +508,10 @@ export default function SessionsPage() {
{total} {total}
</Badge>, </Badge>,
); );
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
{searching ? (
<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" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pr-7 pl-8 text-xs"
/>
{search && (
<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 />
</Button>
)}
</div>,
);
return () => { return () => {
setAfterTitle(null); setAfterTitle(null);
setEnd(null);
}; };
}, [ }, [loading, setAfterTitle, total]);
loading,
search,
searching,
setAfterTitle,
setEnd,
t.common.clear,
t.sessions.searchPlaceholder,
total,
]);
const loadSessions = useCallback((p: number) => { const loadSessions = useCallback((p: number) => {
setLoading(true); setLoading(true);
@ -591,6 +622,16 @@ export default function SessionsPage() {
.filter((s) => !s.is_active) .filter((s) => !s.is_active)
.slice(0, 5); .slice(0, 5);
const isSearching = Boolean(search.trim());
const showOverviewTab =
platformEntries.length > 0 || recentSessions.length > 0;
const showList = view === "list" || isSearching || !showOverviewTab;
const showPagination = showList && !searchResults && total > PAGE_SIZE;
useEffect(() => {
if (isSearching) setView("list");
}, [isSearching]);
const alerts: { message: string; detail?: string }[] = []; const alerts: { message: string; detail?: string }[] = [];
if (status) { if (status) {
if (status.gateway_state === "startup_failed") { if (status.gateway_state === "startup_failed") {
@ -692,7 +733,7 @@ export default function SessionsPage() {
? "destructive" ? "destructive"
: "outline" : "outline"
} }
className="text-[10px] shrink-0" className="text-xs shrink-0"
> >
{actionStatus?.running {actionStatus?.running
? t.status.running ? t.status.running
@ -708,7 +749,7 @@ export default function SessionsPage() {
ghost ghost
size="icon" size="icon"
onClick={dismissLog} onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100" className="shrink-0 text-text-secondary hover:text-foreground"
aria-label={t.common.close} aria-label={t.common.close}
> >
<X /> <X />
@ -717,7 +758,7 @@ export default function SessionsPage() {
<pre <pre
ref={logScrollRef} ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all" className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-xs leading-relaxed whitespace-pre-wrap break-all"
> >
{actionStatus?.lines && actionStatus.lines.length > 0 {actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n") ? actionStatus.lines.join("\n")
@ -726,126 +767,170 @@ export default function SessionsPage() {
</div> </div>
)} )}
{platformEntries.length > 0 && status && ( {(showOverviewTab && !isSearching) || showList ? (
<PlatformsCard platforms={platformEntries} /> <div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:gap-3">
)} <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
{showOverviewTab && !isSearching && (
{recentSessions.length > 0 && ( <Segmented
<Card className="min-w-0 max-w-full overflow-hidden"> className="w-fit shrink-0"
<CardHeader className="min-w-0"> size="md"
<div className="flex min-w-0 items-center gap-2"> value={view}
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" /> onChange={setView}
<CardTitle className="min-w-0 truncate text-base"> options={[
{t.status.recentSessions} { value: "overview", label: t.sessions.overview },
</CardTitle> { value: "list", label: t.sessions.title },
</div> ]}
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-[10px] sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/> />
))} )}
{showList && (
<div className="relative min-w-0 w-full sm:w-auto sm:min-w-[12rem] sm:max-w-md sm:flex-1">
{searching ? (
<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" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
<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 />
</Button>
)}
</div>
)}
</div> </div>
{!searchResults && total > PAGE_SIZE && ( {showPagination && (
<div className="flex items-center justify-between pt-2"> <SessionsPagination
<span className="text-xs text-muted-foreground"> compact
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{" "} className="shrink-0 sm:ml-auto"
{t.common.of} {total} page={page}
</span> total={total}
<div className="flex items-center gap-1"> onPageChange={setPage}
<Button />
outlined
size="icon"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label={t.sessions.previousPage}
>
<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
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
)} )}
</> </div>
) : null}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-text-tertiary">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>
{showPagination && (
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
/>
)}
</>
)
) : (
<div className="flex min-w-0 flex-col gap-4">
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-mondwest normal-case min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span>
{s.preview && (
<p className="font-mondwest normal-case min-w-0 max-w-full text-xs leading-snug text-text-tertiary [overflow-wrap:anywhere]">
{s.preview}
</p>
)}
</div>
<Badge
tone="outline"
className="shrink-0 self-start text-xs sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
)} )}
<PluginSlot name="sessions:bottom" /> <PluginSlot name="sessions:bottom" />
</div> </div>
); );
} }
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
onPageChange: (page: number) => void;
page: number;
total: number;
}

View File

@ -258,8 +258,8 @@ export default function SkillsPage() {
<div className="sm:sticky sm:top-0"> <div className="sm:sticky sm:top-0">
<div className="flex flex-col rounded-none border border-border bg-muted/20"> <div className="flex flex-col rounded-none border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border"> <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" /> <Filter className="h-3 w-3 text-text-tertiary" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground"> <span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.skills.filters} {t.skills.filters}
</span> </span>
</div> </div>
@ -290,7 +290,7 @@ export default function SkillsPage() {
!isSearching && !isSearching &&
allCategories.length > 0 && ( allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border"> <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"> <div className="px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
{t.skills.categories} {t.skills.categories}
</div> </div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto"> <div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
@ -304,14 +304,14 @@ export default function SkillsPage() {
onClick={() => onClick={() =>
setActiveCategory(isActive ? null : key) setActiveCategory(isActive ? null : key)
} }
className="rounded-none px-2 py-1 text-[11px]" className="rounded-none px-2 py-1 text-xs"
> >
<span className="flex-1 truncate">{name}</span> <span className="flex-1 truncate">{name}</span>
<span <span
className={`text-[10px] tabular-nums ${ className={`text-xs tabular-nums ${
isActive isActive
? "text-foreground/60" ? "text-text-secondary"
: "text-muted-foreground/50" : "text-text-tertiary"
}`} }`}
> >
{count} {count}
@ -335,7 +335,7 @@ export default function SkillsPage() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
{t.skills.title} {t.skills.title}
</CardTitle> </CardTitle>
<Badge tone="secondary" className="text-[10px]"> <Badge tone="secondary" className="text-xs">
{t.skills.resultCount {t.skills.resultCount
.replace("{count}", String(searchMatchedSkills.length)) .replace("{count}", String(searchMatchedSkills.length))
.replace( .replace(
@ -379,7 +379,7 @@ export default function SkillsPage() {
) )
: t.skills.all} : t.skills.all}
</CardTitle> </CardTitle>
<Badge tone="secondary" className="text-[10px]"> <Badge tone="secondary" className="text-xs">
{t.skills.skillCount {t.skills.skillCount
.replace("{count}", String(activeSkills.length)) .replace("{count}", String(activeSkills.length))
.replace("{s}", activeSkills.length !== 1 ? "s" : "")} .replace("{s}", activeSkills.length !== 1 ? "s" : "")}
@ -437,18 +437,18 @@ export default function SkillsPage() {
</span> </span>
<Badge <Badge
tone={ts.enabled ? "success" : "outline"} tone={ts.enabled ? "success" : "outline"}
className="text-[10px]" className="text-xs"
> >
{ts.enabled {ts.enabled
? t.common.active ? t.common.active
: t.common.inactive} : t.common.inactive}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mb-2"> <p className="text-xs text-text-secondary mb-2">
{ts.description} {ts.description}
</p> </p>
{ts.enabled && !ts.configured && ( {ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2"> <p className="text-xs text-amber-300 mb-2">
{t.skills.setupNeeded} {t.skills.setupNeeded}
</p> </p>
)} )}
@ -458,7 +458,7 @@ export default function SkillsPage() {
<Badge <Badge
key={tool} key={tool}
tone="secondary" tone="secondary"
className="text-[10px] font-mono" className="text-xs font-mono"
> >
{tool} {tool}
</Badge> </Badge>
@ -466,7 +466,7 @@ export default function SkillsPage() {
</div> </div>
)} )}
{ts.tools.length === 0 && ( {ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60"> <span className="text-xs text-text-tertiary">
{ts.enabled {ts.enabled
? t.skills.toolsetLabel.replace( ? t.skills.toolsetLabel.replace(
"{name}", "{name}",

View File

@ -35,7 +35,7 @@ export function PluginPage({ name }: { name: string }) {
<div <div
className={cn( className={cn(
"max-w-lg p-4", "max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-midground/80", "font-mondwest text-sm tracking-[0.08em] text-text-secondary",
)} )}
role="alert" role="alert"
> >
@ -48,7 +48,7 @@ export function PluginPage({ name }: { name: string }) {
<div <div
className={cn( className={cn(
"flex items-center gap-2 p-4", "flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-midground/60", "font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
)} )}
> >
<Spinner className="shrink-0" /> <Spinner className="shrink-0" />