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:50:32 -07:00
committed by Teknium
co-authored by Hermes
parent dc4b0465b5
commit 487c398dcf
54 changed files with 988 additions and 735 deletions
+7 -6
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)
+1 -1
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"; };
+56
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.
+45 -22
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"
} }
+1 -1
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",
+21 -15
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 ? (
+2 -2
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>
); );
} }
+3 -2
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>
+5 -5
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>
) : ( ) : (
+2 -2
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>
+6 -6
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>
)} )}
+6 -5
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>
); );
+2 -1
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"
+35 -31
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>
+3 -3
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>
)} )}
+2 -3
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",
)} )}
+6 -6
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>
+1 -1
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>
)} )}
+5 -5
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>
)} )}
+5 -5
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";
} }
+14 -3
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>) {
+4 -3
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>
+5 -2
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}",
+5 -2
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",
+5 -2
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}",
+5 -2
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}",
+5 -2
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}",
+5 -2
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}",
+5 -2
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",
+5 -2
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}",
+5 -2
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} 後に期限切れになります",
+5 -2
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} 후 만료됩니다",
+5 -2
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}",
+5 -2
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}",
+5 -2
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",
+3
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;
}; };
+5 -2
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}",
+5 -2
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} 後過期",
+5 -2
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} 后过期",
+5 -1
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;
+10 -5
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" },
+9
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;
+24 -22
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>
+10 -11
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 }}
+13 -14
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}",
+7 -5
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>
+99 -112
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>
); );
} }
+27 -24
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>
+57 -56
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>
+65 -68
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}
+19 -14
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>
+312 -227
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;
}
+14 -14
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}",
+2 -2
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" />