feat(web): mobile dashboard UX polish (#28127)

* feat(web): mobile dashboard UX polish

Bottom sheets for sidebar theme/language pickers on narrow viewports with
enter/exit animation and drag-to-close; inline header badges beside titles;
bottom padding on the route outlet for scroll clearance; profiles loading uses a
unicode braille spinner; align profile/cron card actions to the top; viewport-fit
cover and supporting layout tweaks across dashboard pages.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Nix web npm hash and mobile sheet accessibility.

Align fetchNpmDeps in nix/web.nix with web/package-lock.json for CI. Improve BottomPickSheet backdrop labeling, avoid aria-hidden on the dialog during exit animation, and wire theme/language sheets with listbox semantics and localized dismiss labels.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett 2026-05-18 15:20:31 -04:00 committed by GitHub
parent 52e3bfc2f4
commit 6fa1701bd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 779 additions and 295 deletions

View file

@ -4,7 +4,7 @@ let
src = ../web; src = ../web;
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
inherit src; inherit src;
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI="; hash = "sha256-H98reD4N++WroZOQ9NFrKtC5aiHj6KqaYDzUOiZA2bE=";
}; };
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View file

@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Hermes Agent - Dashboard</title> <title>Hermes Agent - Dashboard</title>
</head> </head>
<body> <body>

33
web/package-lock.json generated
View file

@ -19,6 +19,7 @@
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flag-icons": "^7.5.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"leva": "^0.10.1", "leva": "^0.10.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@ -76,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",
@ -1124,6 +1126,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",
@ -1776,6 +1779,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": "*",
@ -2481,6 +2485,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"
} }
@ -2490,6 +2495,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"
} }
@ -2500,6 +2506,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"
} }
@ -2564,6 +2571,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",
@ -2892,6 +2900,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"
}, },
@ -3044,6 +3053,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",
@ -3551,6 +3561,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"
} }
@ -3864,6 +3875,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",
@ -4143,6 +4155,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -4242,7 +4260,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",
@ -4548,6 +4567,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",
@ -4986,6 +5006,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^20.0.0 || >=22.0.0" "node": "^20.0.0 || >=22.0.0"
} }
@ -5113,6 +5134,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"
}, },
@ -5184,6 +5206,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"
} }
@ -5203,6 +5226,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"
}, },
@ -5562,7 +5586,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",
@ -5627,6 +5652,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"
@ -5725,6 +5751,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"
} }
@ -5740,6 +5767,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",
@ -5861,6 +5889,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -24,6 +24,7 @@
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flag-icons": "^7.5.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"leva": "^0.10.1", "leva": "^0.10.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",

View file

@ -424,8 +424,8 @@ export default function App() {
<header <header
className={cn( className={cn(
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12", "lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
"flex items-center gap-2 px-3", "flex items-center gap-2 px-4 py-2",
"border-b border-current/20", "border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm", "bg-background-base/90 backdrop-blur-sm",
)} )}
@ -469,7 +469,7 @@ export default function App() {
<PluginSlot name="header-banner" /> <PluginSlot name="header-banner" />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1"> <div className="flex min-h-0 min-w-0 flex-1">
<aside <aside
id="app-sidebar" id="app-sidebar"
@ -575,7 +575,7 @@ export default function App() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<PluginSlot name="header-right" /> <PluginSlot name="header-right" />
<ThemeSwitcher dropUp /> <ThemeSwitcher dropUp />
<LanguageSwitcher /> <LanguageSwitcher dropUp />
</div> </div>
</div> </div>
@ -588,8 +588,8 @@ export default function App() {
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
"px-3 sm:px-6", "px-3 sm:px-6",
isChatRoute isChatRoute
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4" ? "pb-0 pt-1 sm:pt-2 lg:pt-4"
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8", : "pt-2 sm:pt-4 lg:pt-6",
isDocsRoute && "min-h-0 flex-1", isDocsRoute && "min-h-0 flex-1",
)} )}
> >
@ -597,6 +597,8 @@ export default function App() {
<div <div
className={cn( className={cn(
"w-full min-w-0", "w-full min-w-0",
!isChatRoute &&
"pb-[calc(2rem+env(safe-area-inset-bottom,0px))] lg:pb-8",
(isDocsRoute || isChatRoute) && (isDocsRoute || isChatRoute) &&
"min-h-0 flex flex-1 flex-col", "min-h-0 flex flex-1 flex-col",
)} )}

View file

@ -0,0 +1,224 @@
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
const CLOSE_DRAG_MIN_PX = 72;
const CLOSE_DRAG_RATIO = 0.18;
const SHEET_TRANSITION_MS = 280;
/**
* Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body`
* so nested overflow/transform in the sidebar cannot clip menus (theme /
* language switchers). Open/close uses slide + fade; teardown is delayed until
* the exit animation finishes so animations can complete.
*
* Drag the header/handle downward to dismiss (skipped when reduced motion is on).
*/
export function BottomPickSheet({
backdropDismissLabel = "Dismiss",
children,
onClose,
open,
title,
}: BottomPickSheetProps) {
const [renderPortal, setRenderPortal] = useState(open);
const [entered, setEntered] = useState(false);
const [dragOffsetPx, setDragOffsetPx] = useState(0);
const [dragActive, setDragActive] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sheetRef = useRef<HTMLDivElement>(null);
const dragTrackingRef = useRef(false);
const dragStartYRef = useRef(0);
const dragOffsetRef = useRef(0);
const reducedMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const syncDragPx = (next: number) => {
dragOffsetRef.current = next;
setDragOffsetPx(next);
};
useEffect(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS;
let openRafId = 0;
let exitRafId = 0;
if (open) {
openRafId = requestAnimationFrame(() => {
dragTrackingRef.current = false;
dragOffsetRef.current = 0;
setDragActive(false);
setDragOffsetPx(0);
setRenderPortal(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setEntered(true));
});
});
} else {
exitRafId = requestAnimationFrame(() => {
dragTrackingRef.current = false;
setDragActive(false);
setEntered(false);
closeTimerRef.current = window.setTimeout(() => {
dragOffsetRef.current = 0;
setDragOffsetPx(0);
setRenderPortal(false);
closeTimerRef.current = null;
}, ms);
});
}
return () => {
cancelAnimationFrame(openRafId);
cancelAnimationFrame(exitRafId);
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [open, reducedMotion]);
useEffect(() => {
if (!renderPortal) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [renderPortal]);
if (!renderPortal || typeof document === "undefined") return null;
const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]";
const draggingVisual = dragActive || dragOffsetPx > 0;
const onDragPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (reducedMotion || !entered) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
dragTrackingRef.current = true;
setDragActive(true);
dragStartYRef.current = e.clientY;
syncDragPx(0);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onDragPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragTrackingRef.current) return;
const dy = e.clientY - dragStartYRef.current;
const next = Math.max(0, dy);
const sheetH = sheetRef.current?.offsetHeight ?? 560;
syncDragPx(Math.min(next, sheetH));
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragTrackingRef.current) return;
dragTrackingRef.current = false;
setDragActive(false);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
const sheetH = sheetRef.current?.offsetHeight ?? 560;
const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO);
const d = dragOffsetRef.current;
if (d >= threshold) {
onClose();
return;
}
syncDragPx(0);
};
return createPortal(
<div className="fixed inset-0 z-[200] flex flex-col justify-end">
<button
type="button"
aria-label={backdropDismissLabel}
className={cn(
"absolute inset-0 bg-black/55 backdrop-blur-[2px]",
"transition-opacity ease-out motion-reduce:transition-none",
durationClass,
entered ? "opacity-100" : "opacity-0",
)}
onClick={onClose}
/>
<div
aria-label={title}
aria-modal="true"
ref={sheetRef}
className={cn(
"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))]",
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
"ease-out motion-reduce:transition-none transform-gpu",
draggingVisual ? "transition-none" : cn("transition-transform", durationClass),
entered ? "translate-y-0" : "translate-y-full",
)}
role="dialog"
style={
entered && dragOffsetPx > 0
? { transform: `translateY(${dragOffsetPx}px)` }
: undefined
}
>
<div
className={cn(
"flex shrink-0 flex-col gap-2 border-b border-current/15 px-4 pb-3 pt-2",
"touch-none select-none",
reducedMotion ? "cursor-default" : "cursor-grab active:cursor-grabbing",
)}
onPointerCancel={endDrag}
onPointerDown={onDragPointerDown}
onPointerMove={onDragPointerMove}
onPointerUp={endDrag}
>
<div
aria-hidden
className="mx-auto h-1 w-10 shrink-0 rounded-full bg-current/20"
/>
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
>
{title}
</Typography>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{children}
</div>
</div>
</div>,
document.body,
);
}
interface BottomPickSheetProps {
backdropDismissLabel?: string;
children: ReactNode;
onClose: () => void;
open: boolean;
title: string;
}

View file

@ -1,9 +1,12 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Button } from "@nous-research/ui/ui/components/button"; import { Button } from "@nous-research/ui/ui/components/button";
import { BottomPickSheet } from "@/components/BottomPickSheet";
import { Typography } from "@/components/NouiTypography"; import { Typography } from "@/components/NouiTypography";
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
import { useI18n } from "@/i18n/context"; import { useI18n } from "@/i18n/context";
import { LOCALE_META } from "@/i18n"; import { LOCALE_META } from "@/i18n";
import type { Locale } from "@/i18n"; import type { Locale } from "@/i18n";
import { cn } from "@/lib/utils";
/** /**
* Language picker shows the current language's flag + endonym, opens a * Language picker shows the current language's flag + endonym, opens a
@ -12,15 +15,34 @@ import type { Locale } from "@/i18n";
* *
* Replaces the older two-state ENZH toggle now that we ship 16 locales * Replaces the older two-state ENZH toggle now that we ship 16 locales
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu). * (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
*
* Locale markers use lipis/flag-icons (SVG sprites) instead of emoji so flags
* render consistently across platforms.
*
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
* `dropUp` so the list opens above the trigger and avoids clipping below the
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
*/ */
export function LanguageSwitcher() { export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n(); const { locale, setLocale, t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
// Close on outside click / Escape so the dropdown doesn't trap the user.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
useEffect(() => {
if (!open || useMobileSheet) return;
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (!containerRef.current) return; if (!containerRef.current) return;
@ -28,20 +50,14 @@ export function LanguageSwitcher() {
setOpen(false); setOpen(false);
} }
} }
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown); document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey); return () => document.removeEventListener("pointerdown", onPointerDown);
return () => { }, [open, useMobileSheet]);
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const current = LOCALE_META[locale]; const current = LOCALE_META[locale];
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>; const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
const sheetTitle = t.language.switchTo;
return ( return (
<div ref={containerRef} className="relative inline-flex"> <div ref={containerRef} className="relative inline-flex">
@ -55,7 +71,7 @@ export function LanguageSwitcher() {
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-muted-foreground hover:text-foreground"
> >
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="text-base leading-none">{current.flag}</span> <LocaleFlagIcon countryCode={current.flagCountryCode} />
<Typography <Typography
mondwest mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]" className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
@ -65,36 +81,103 @@ export function LanguageSwitcher() {
</span> </span>
</Button> </Button>
{open && ( {useMobileSheet && (
<div <BottomPickSheet
role="listbox" backdropDismissLabel={t.common.close}
aria-label={t.language.switchTo} onClose={() => setOpen(false)}
className="absolute right-0 top-full mt-1 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto" open={open}
title={sheetTitle}
> >
{allLocales.map(([code, meta]) => { <div aria-label={sheetTitle} role="listbox">
const selected = code === locale; <LanguageSwitcherOptions
return ( allLocales={allLocales}
<button locale={locale}
key={code} setLocale={setLocale}
role="option" setOpen={setOpen}
aria-selected={selected} />
onClick={() => { </div>
setLocale(code); </BottomPickSheet>
setOpen(false); )}
}}
className={ {open && !useMobileSheet && (
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " + <div
(selected ? "font-semibold text-foreground" : "text-muted-foreground") aria-label={sheetTitle}
} className={cn(
> "absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
<span className="text-base leading-none">{meta.flag}</span> dropUp ? "bottom-full mb-1" : "top-full mt-1",
<span className="truncate">{meta.name}</span> )}
{selected && <span className="ml-auto text-xs"></span>} role="listbox"
</button> >
); <LanguageSwitcherOptions
})} allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div> </div>
)} )}
</div> </div>
); );
} }
function LanguageSwitcherOptions({
allLocales,
locale,
setLocale,
setOpen,
}: LanguageSwitcherOptionsProps) {
return (
<>
{allLocales.map(([code, meta]) => {
const selected = code === locale;
return (
<button
aria-selected={selected}
className={
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
}
key={code}
onClick={() => {
setLocale(code);
setOpen(false);
}}
role="option"
type="button"
>
<LocaleFlagIcon countryCode={meta.flagCountryCode} />
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
</button>
);
})}
</>
);
}
function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) {
return (
<span
aria-hidden
className={cn("fi fis shrink-0 text-base leading-none", `fi-${countryCode}`)}
/>
);
}
interface LanguageSwitcherOptionsProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
locale: Locale;
setLocale: (code: Locale) => void;
setOpen: (open: boolean) => void;
}
interface LanguageSwitcherProps {
dropUp?: boolean;
}
interface LocaleFlagIconProps {
countryCode: string;
}

View file

@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Palette, Check } from "lucide-react"; import { Palette, Check } from "lucide-react";
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 { BottomPickSheet } from "@/components/BottomPickSheet";
import { Typography } from "@/components/NouiTypography"; import { Typography } from "@/components/NouiTypography";
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
import { BUILTIN_THEMES, useTheme } from "@/themes"; import { BUILTIN_THEMES, useTheme } from "@/themes";
import type { DashboardTheme } from "@/themes"; import type { DashboardTheme, ThemeListEntry } from "@/themes";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,18 +19,31 @@ import { cn } from "@/lib/utils";
* *
* When placed at the bottom of a container (e.g. the sidebar rail), pass * When placed at the bottom of a container (e.g. the sidebar rail), pass
* `dropUp` so the menu opens above the trigger instead of clipping below * `dropUp` so the menu opens above the trigger instead of clipping below
* the viewport. * the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` so the picker is not clipped by
* the sidebar (same idea as a responsive Drawer).
*/ */
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
const { themeName, availableThemes, setTheme } = useTheme(); const { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
useEffect(() => {
if (!open || useMobileSheet) return;
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
if ( if (
wrapperRef.current && wrapperRef.current &&
@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
close(); close();
} }
}; };
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("mousedown", onMouseDown); document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKey); return () => document.removeEventListener("mousedown", onMouseDown);
return () => { }, [open, close, useMobileSheet]);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open, close]);
const current = availableThemes.find((th) => th.name === themeName); const current = availableThemes.find((th) => th.name === themeName);
const label = current?.label ?? themeName; const label = current?.label ?? themeName;
const sheetTitle = t.theme?.title ?? "Theme";
return ( return (
<div ref={wrapperRef} className="relative"> <div ref={wrapperRef} className="relative">
@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
</span> </span>
</Button> </Button>
{open && ( {useMobileSheet && (
<BottomPickSheet
backdropDismissLabel={t.common.close}
onClose={close}
open={open}
title={sheetTitle}
>
<div aria-label={sheetTitle} role="listbox">
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div <div
role="listbox" aria-label={sheetTitle}
aria-label={t.theme?.title ?? "Theme"}
className={cn( className={cn(
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto", "absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1", dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
"border border-current/20 bg-background-base/95 backdrop-blur-sm", "border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
)} )}
role="listbox"
> >
<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-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
> >
{t.theme?.title ?? "Theme"} {sheetTitle}
</Typography> </Typography>
</div> </div>
{availableThemes.map((th) => { <ThemeSwitcherOptions
const isActive = th.name === themeName; availableThemes={availableThemes}
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; close={close}
setTheme={setTheme}
return ( themeName={themeName}
<ListItem />
key={th.name}
active={isActive}
role="option"
aria-selected={isActive}
onClick={() => {
setTheme(th.name);
close();
}}
className="gap-3"
>
{paletteTheme ? (
<ThemeSwatch theme={paletteTheme} />
) : (
<PlaceholderSwatch />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-[0.75rem] tracking-wide uppercase"
>
{th.label}
</Typography>
{th.description && (
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
{th.description}
</Typography>
)}
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
);
})}
</div> </div>
)} )}
</div> </div>
); );
} }
function ThemeSwitcherOptions({
availableThemes,
close,
setTheme,
themeName,
}: ThemeSwitcherOptionsProps) {
return (
<>
{availableThemes.map((th) => {
const isActive = th.name === themeName;
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
return (
<ListItem
active={isActive}
aria-selected={isActive}
className="gap-3"
key={th.name}
onClick={() => {
setTheme(th.name);
close();
}}
role="option"
>
{paletteTheme ? (
<ThemeSwatch theme={paletteTheme} />
) : (
<PlaceholderSwatch />
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<Typography
mondwest
className="truncate text-[0.75rem] tracking-wide uppercase"
>
{th.label}
</Typography>
{th.description && (
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
{th.description}
</Typography>
)}
</div>
<Check
className={cn(
"h-3 w-3 shrink-0 text-midground",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</ListItem>
);
})}
</>
);
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) { function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette; const { background, midground, warmGlow } = theme.palette;
return ( return (
@ -168,6 +213,13 @@ function PlaceholderSwatch() {
); );
} }
interface ThemeSwitcherOptionsProps {
availableThemes: ThemeListEntry[];
close: () => void;
setTheme: (name: string) => void;
themeName: string;
}
interface ThemeSwitcherProps { interface ThemeSwitcherProps {
dropUp?: boolean; dropUp?: boolean;
} }

View file

@ -35,6 +35,9 @@ export function PageHeaderProvider({
const displayTitle = titleOverride ?? defaultTitle; const displayTitle = titleOverride ?? defaultTitle;
const isChatRoute = pathname === "/chat" || pathname === "/chat/"; const isChatRoute = pathname === "/chat" || pathname === "/chat/";
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
const isEnvRoute =
pathname === "/env" || pathname.startsWith("/env/");
const value = useMemo( const value = useMemo(
() => ({ () => ({
@ -51,37 +54,65 @@ export function PageHeaderProvider({
<header <header
className={cn( className={cn(
"z-1 w-full shrink-0", "z-1 w-full shrink-0",
"box-border h-14 min-h-14", "box-border border-b border-current/20",
"border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm", "bg-background-base/40 backdrop-blur-sm",
"overflow-hidden", // Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
"sm:min-h-0", "min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
)} )}
role="banner" role="banner"
> >
<div <div
className={cn( className={cn(
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0", "flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
isChatRoute isChatRoute
? "flex-row items-center" ? "flex-row items-center"
: "flex-col justify-center sm:flex-row sm:items-center", : "flex-col justify-center sm:flex-row sm:items-center",
)} )}
> >
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> <div
className={cn(
"flex min-w-0 flex-1 gap-2 sm:gap-3",
afterTitle && isEnvRoute
? "flex-col items-start sm:flex-row sm:items-center"
: afterTitle
? "flex-row flex-wrap items-center"
: "flex-row items-center",
)}
>
<h1 <h1
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground" className={cn(
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
afterTitle && isEnvRoute
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
: afterTitle
? "shrink truncate"
: "truncate",
)}
style={{ mixBlendMode: "plus-lighter" }} style={{ mixBlendMode: "plus-lighter" }}
> >
{displayTitle} {displayTitle}
</h1> </h1>
{afterTitle} {afterTitle ? (
<div
className={cn(
"min-w-0 scrollbar-none",
isEnvRoute
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
: "shrink-0 overflow-visible",
)}
>
{afterTitle}
</div>
) : null}
</div> </div>
{end ? ( {end ? (
<div <div
className={cn( className={cn(
"flex min-w-0 justify-end sm:max-w-md sm:flex-1", "flex min-w-0 sm:max-w-md sm:flex-1",
isChatRoute ? "w-auto shrink-0" : "w-full", isChatRoute
? "w-auto shrink-0 justify-end"
: "w-full justify-start sm:justify-end",
)} )}
> >
{end} {end}
@ -93,6 +124,8 @@ export function PageHeaderProvider({
<main <main
className={cn( className={cn(
"min-h-0 w-full min-w-0 flex-1 flex flex-col", "min-h-0 w-full min-w-0 flex-1 flex flex-col",
// Bottom inset for scrolled pages lives on the route outlet wrapper in
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
isChatRoute isChatRoute
? "overflow-hidden" ? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]", : "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",

View file

@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
/** True when viewport width is strictly below `px` (matches Tailwind `min-width: px`). */
export function useBelowBreakpoint(px: number) {
const query = `(max-width: ${px - 1}px)`;
const [matches, setMatches] = useState(() =>
typeof window !== "undefined" ? window.matchMedia(query).matches : false,
);
useEffect(() => {
const mql = window.matchMedia(query);
const sync = () => setMatches(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, [query]);
return matches;
}

View file

@ -38,25 +38,26 @@ const TRANSLATIONS: Record<Locale, Translations> = {
// Display metadata for the language picker — endonym (native name) so users // Display metadata for the language picker — endonym (native name) so users
// recognize their language even if they don't speak the current UI language, // recognize their language even if they don't speak the current UI language,
// plus a flag emoji for visual scanning. Exposed as a constant so the // plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning.
// LanguageSwitcher and any future settings page can share the same list. // Exposed as a constant so the LanguageSwitcher and any future settings page
export const LOCALE_META: Record<Locale, { name: string; flag: string }> = { // can share the same list.
en: { name: "English", flag: "🇬🇧" }, export const LOCALE_META: Record<Locale, { name: string; flagCountryCode: string }> = {
zh: { name: "简体中文", flag: "🇨🇳" }, en: { name: "English", flagCountryCode: "gb" },
"zh-hant": { name: "繁體中文", flag: "🇹🇼" }, zh: { name: "简体中文", flagCountryCode: "cn" },
ja: { name: "日本語", flag: "🇯🇵" }, "zh-hant": { name: "繁體中文", flagCountryCode: "tw" },
de: { name: "Deutsch", flag: "🇩🇪" }, ja: { name: "日本語", flagCountryCode: "jp" },
es: { name: "Español", flag: "🇪🇸" }, de: { name: "Deutsch", flagCountryCode: "de" },
fr: { name: "Français", flag: "🇫🇷" }, es: { name: "Español", flagCountryCode: "es" },
tr: { name: "Türkçe", flag: "🇹🇷" }, fr: { name: "Français", flagCountryCode: "fr" },
uk: { name: "Українська", flag: "🇺🇦" }, tr: { name: "Türkçe", flagCountryCode: "tr" },
af: { name: "Afrikaans", flag: "🇿🇦" }, uk: { name: "Українська", flagCountryCode: "ua" },
ko: { name: "한국어", flag: "🇰🇷" }, af: { name: "Afrikaans", flagCountryCode: "za" },
it: { name: "Italiano", flag: "🇮🇹" }, ko: { name: "한국어", flagCountryCode: "kr" },
ga: { name: "Gaeilge", flag: "🇮🇪" }, it: { name: "Italiano", flagCountryCode: "it" },
pt: { name: "Português", flag: "🇵🇹" }, ga: { name: "Gaeilge", flagCountryCode: "ie" },
ru: { name: "Русский", flag: "🇷🇺" }, pt: { name: "Português", flagCountryCode: "pt" },
hu: { name: "Magyar", flag: "🇭🇺" }, ru: { name: "Русский", flagCountryCode: "ru" },
hu: { name: "Magyar", flagCountryCode: "hu" },
}; };
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]; const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];

View file

@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "flag-icons/css/flag-icons.min.css";
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import { SystemActionsProvider } from "./contexts/SystemActions"; import { SystemActionsProvider } from "./contexts/SystemActions";

View file

@ -439,7 +439,7 @@ export default function AnalyticsPage() {
); );
setEnd( setEnd(
showTokens === false ? null : ( showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2"> <div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<Button <Button

View file

@ -417,14 +417,14 @@ export default function ConfigPage() {
<PluginSlot name="config:top" /> <PluginSlot name="config:top" />
<Toast toast={toast} /> <Toast toast={toast} />
<div className="flex items-center justify-between gap-4"> <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2 sm:flex-1">
<Settings2 className="h-4 w-4 text-muted-foreground" /> <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5"> <code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
{configPath ?? t.config.configPath} {configPath ?? t.config.configPath}
</code> </code>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
<Button <Button
ghost ghost
size="icon" size="icon"

View file

@ -370,7 +370,7 @@ export default function CronPage() {
return ( return (
<Card key={job.id}> <Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4"> <CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate"> <span className="font-medium text-sm truncate">

View file

@ -537,13 +537,16 @@ export default function EnvPage() {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}; };
setAfterTitle( setAfterTitle(
<nav className="flex items-center gap-1" aria-label="Jump to section"> <nav
className="flex shrink-0 flex-nowrap items-center gap-1"
aria-label="Jump to section"
>
{sections.map((s) => ( {sections.map((s) => (
<button <button
key={s.id} key={s.id}
type="button" type="button"
onClick={() => scrollTo(s.id)} onClick={() => scrollTo(s.id)}
className="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 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
> >
{s.label} {s.label}
</button> </button>

View file

@ -46,6 +46,12 @@ const LINE_COLORS: Record<string, string> = {
const toOptions = <T extends string>(values: readonly T[]) => const toOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: v })); values.map((v) => ({ value: v, label: v }));
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";
const segmentedClass =
"w-fit max-w-full flex-wrap justify-start self-start";
export default function LogsPage() { export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL"); const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
@ -87,7 +93,7 @@ export default function LogsPage() {
</span>, </span>,
); );
setEnd( setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3"> <div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={autoRefresh} checked={autoRefresh}
@ -145,39 +151,43 @@ export default function LogsPage() {
}, [autoRefresh, fetchLogs]); }, [autoRefresh, fetchLogs]);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex min-w-0 max-w-full flex-col gap-4">
<PluginSlot name="logs:top" /> <PluginSlot name="logs:top" />
<div <div
role="toolbar" role="toolbar"
aria-label={t.logs.title} aria-label={t.logs.title}
className="flex flex-wrap items-center gap-x-6 gap-y-2" className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
> >
<FilterGroup label={t.logs.file}> <FilterGroup label={t.logs.file} className={filterGroupClass}>
<Segmented <Segmented
className={segmentedClass}
value={file} value={file}
onChange={setFile} onChange={setFile}
options={toOptions(FILES)} options={toOptions(FILES)}
/> />
</FilterGroup> </FilterGroup>
<FilterGroup label={t.logs.level}> <FilterGroup label={t.logs.level} className={filterGroupClass}>
<Segmented <Segmented
className={segmentedClass}
value={level} value={level}
onChange={setLevel} onChange={setLevel}
options={toOptions(LEVELS)} options={toOptions(LEVELS)}
/> />
</FilterGroup> </FilterGroup>
<FilterGroup label={t.logs.component}> <FilterGroup label={t.logs.component} className={filterGroupClass}>
<Segmented <Segmented
className={segmentedClass}
value={component} value={component}
onChange={setComponent} onChange={setComponent}
options={toOptions(COMPONENTS)} options={toOptions(COMPONENTS)}
/> />
</FilterGroup> </FilterGroup>
<FilterGroup label={t.logs.lines}> <FilterGroup label={t.logs.lines} className={filterGroupClass}>
<Segmented <Segmented
className={segmentedClass}
value={String(lineCount)} value={String(lineCount)}
onChange={(v) => onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
@ -190,7 +200,7 @@ export default function LogsPage() {
</FilterGroup> </FilterGroup>
</div> </div>
<Card> <Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@ -206,7 +216,7 @@ export default function LogsPage() {
<div <div
ref={scrollRef} ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]" className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
> >
{lines.length === 0 && !loading && ( {lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8"> <p className="text-muted-foreground text-center py-8">

View file

@ -336,7 +336,9 @@ function ModelCard({
)?.task ?? null; )?.task ?? null;
return ( return (
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}> <Card
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<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">
@ -666,22 +668,20 @@ function ModelSettingsPanel({
).length ?? 0; ).length ?? 0;
return ( return (
<Card> <Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="min-w-0 pb-3">
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex items-center gap-2"> <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<Settings2 className="h-4 w-4 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="text-[10px] text-muted-foreground"> applies to new sessions
applies to new sessions </span>
</span>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-3"> <CardContent className="min-w-0 space-y-3 pt-3">
{/* Main row */} {/* Main row */}
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2"> <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">
<Star className="h-3 w-3 text-primary" /> <Star className="h-3 w-3 text-primary" />
@ -698,14 +698,14 @@ function ModelSettingsPanel({
<Button <Button
size="sm" size="sm"
onClick={() => setPicker({ kind: "main" })} onClick={() => setPicker({ kind: "main" })}
className="text-xs" className="shrink-0 self-start text-xs sm:self-center"
> >
Change Change
</Button> </Button>
</div> </div>
{/* Auxiliary tasks summary + open modal */} {/* Auxiliary tasks summary + open modal */}
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2"> <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-muted-foreground" />
@ -723,7 +723,7 @@ function ModelSettingsPanel({
size="sm" size="sm"
outlined outlined
onClick={() => setAuxModalOpen(true)} onClick={() => setAuxModalOpen(true)}
className="text-xs" className="shrink-0 self-start text-xs sm:self-center"
> >
Configure Configure
</Button> </Button>
@ -827,7 +827,7 @@ export default function ModelsPage() {
</span>, </span>,
); );
setEnd( setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2"> <div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<Button <Button
@ -864,10 +864,10 @@ export default function ModelsPage() {
}, [load]); }, [load]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 max-w-full flex-col gap-6">
<PluginSlot name="models:top" /> <PluginSlot name="models:top" />
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid min-w-0 gap-6 lg:grid-cols-2">
<ModelSettingsPanel <ModelSettingsPanel
aux={aux} aux={aux}
refreshKey={saveKey} refreshKey={saveKey}
@ -875,10 +875,12 @@ export default function ModelsPage() {
/> />
{data && ( {data && (
<Card> <Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="py-6"> <CardContent className="min-w-0 py-6">
<Stats <div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
items={ <Stats
className="min-w-0"
items={
showTokens showTokens
? [ ? [
{ {
@ -920,6 +922,7 @@ export default function ModelsPage() {
] ]
} }
/> />
</div>
{!showTokens && ( {!showTokens && (
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed"> <p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
Token & cost analytics are hidden because the local counts Token & cost analytics are hidden because the local counts
@ -953,7 +956,7 @@ export default function ModelsPage() {
{data && ( {data && (
<> <>
{data.models.length > 0 ? ( {data.models.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.models.map((m, i) => ( {data.models.map((m, i) => (
<ModelCard <ModelCard
key={`${m.model}:${m.provider}`} key={`${m.model}:${m.provider}`}

View file

@ -60,16 +60,18 @@ export default function PluginsPage() {
useEffect(() => { useEffect(() => {
setEnd( setEnd(
<Button <div className="flex w-full min-w-0 justify-start">
ghost <Button
size="sm" ghost
className="shrink-0 gap-2" size="sm"
disabled={loading || rescanBusy} className="w-max max-w-full shrink-0 gap-2"
onClick={() => void onRescan()} disabled={loading || rescanBusy}
> onClick={() => void onRescan()}
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />} >
{t.pluginsPage.refreshDashboard} {rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>, {t.pluginsPage.refreshDashboard}
</Button>
</div>,
); );
return () => setEnd(null); return () => setEnd(null);
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]); }, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<div className="min-w-0 flex-1"> <span className="truncate font-semibold">{row.name}</span>
<div className="flex flex-wrap items-center gap-3"> <Badge tone="outline">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<span className="truncate font-semibold">{row.name}</span> <Badge tone="outline">v{row.version || "—"}</Badge>
<Badge tone="outline"> <Badge tone={badgeTone}>{row.runtime_status}</Badge>
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
{row.auth_required ? (
<Badge tone="outline">v{row.version || "—"}</Badge> <Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
{row.description ? (
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
{row.description}
</p>
) : null} ) : null}
</div> </div>
@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
</div> </div>
</div> </div>
{row.description ? (
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
{row.description}
</p>
) : null}
{dm?.slots?.length ? ( {dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case"> <p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react"; import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
import spinners from "unicode-animations";
import { H2 } from "@/components/NouiTypography"; import { H2 } from "@/components/NouiTypography";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { ProfileInfo } from "@/lib/api"; import type { ProfileInfo } from "@/lib/api";
@ -21,6 +22,35 @@ import { usePageHeader } from "@/contexts/usePageHeader";
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST. // invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
function ProfilesLoadingSpinner() {
const { frames, interval } = spinners.braille;
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
const id = window.setInterval(
() => setFrameIndex((i) => (i + 1) % frames.length),
interval,
);
return () => window.clearInterval(id);
}, [frames.length, interval]);
return (
<span
aria-hidden
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
>
{frames[frameIndex]}
</span>
);
}
export default function ProfilesPage() { export default function ProfilesPage() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]); const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -199,8 +229,14 @@ export default function ProfilesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-24"> <div
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> aria-busy="true"
aria-live="polite"
className="flex items-center justify-center py-24"
>
<span className="sr-only">{t.common.loading}</span>
<ProfilesLoadingSpinner />
</div> </div>
); );
} }
@ -318,7 +354,7 @@ export default function ProfilesPage() {
const isEditingSoul = editingSoulFor === p.name; const isEditingSoul = editingSoulFor === p.name;
return ( return (
<Card key={p.name}> <Card key={p.name}>
<CardContent className="flex items-center gap-4 py-4"> <CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
{isRenaming ? ( {isRenaming ? (

View file

@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last)); parts.push(snippet.slice(last));
} }
return ( return (
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5"> <p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
{parts} {parts}
</p> </p>
); );
@ -296,24 +296,24 @@ function SessionRow({
return ( return (
<div <div
className={`border overflow-hidden transition-colors ${ className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active session.is_active
? "border-success/30 bg-success/[0.03]" ? "border-success/30 bg-success/[0.03]"
: "border-border" : "border-border"
}`} }`}
> >
<div <div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors" className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle} onClick={onToggle}
> >
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<div className={`shrink-0 ${sourceInfo.color}`}> <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 flex-col gap-0.5 min-w-0"> <div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`} className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
> >
{hasTitle {hasTitle
? session.title ? session.title
@ -322,71 +322,70 @@ function SessionRow({
: t.sessions.untitledSession} : t.sessions.untitledSession}
</span> </span>
{session.is_active && ( {session.is_active && (
<Badge tone="success" className="text-[10px] shrink-0"> <Badge tone="success" className="shrink-0 text-[10px]">
<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>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <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="truncate max-w-[120px] sm:max-w-[180px]"> <span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()} {(session.model ?? t.common.unknown).split("/").pop()}
</span> </span>
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
<span> <span className="shrink-0">
{session.message_count} {t.common.msgs} {session.message_count} {t.common.msgs}
</span> </span>
{session.tool_call_count > 0 && ( {session.tool_call_count > 0 && (
<> <>
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
<span> <span className="shrink-0">
{session.tool_call_count} {t.common.tools} {session.tool_call_count} {t.common.tools}
</span> </span>
</> </>
)} )}
<span className="text-border">&#183;</span> <span className="text-border">&#183;</span>
<span>{timeAgo(session.last_active)}</span> <span className="shrink-0">{timeAgo(session.last_active)}</span>
</div> </div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div> </div>
</div> {snippet && <SnippetHighlight snippet={snippet} />}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 shrink-0"> <Badge tone="outline" className="text-[10px]">
<Badge tone="outline" className="text-[10px]"> {session.source ?? "local"}
{session.source ?? "local"} </Badge>
</Badge> {resumeInChatEnabled && (
{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 <Button
ghost ghost
destructive
size="icon" size="icon"
className="text-muted-foreground hover:text-success" aria-label={t.sessions.deleteSession}
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`); onDelete();
}} }}
> >
<Play /> <Trash2 />
</Button> </Button>
)} </div>
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</div> </div>
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="border-t border-border bg-background/50 p-4"> <div className="min-w-0 border-t border-border bg-background/50 p-4">
{loading && ( {loading && (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" /> <Spinner className="text-xl text-primary" />
@ -624,7 +623,7 @@ export default function SessionsPage() {
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex min-w-0 w-full max-w-full flex-col gap-4">
<PluginSlot name="sessions:top" /> <PluginSlot name="sessions:top" />
<Toast toast={toast} /> <Toast toast={toast} />
@ -732,28 +731,28 @@ export default function SessionsPage() {
)} )}
{recentSessions.length > 0 && ( {recentSessions.length > 0 && (
<Card> <Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader> <CardHeader className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" /> <Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-base"> <CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions} {t.status.recentSessions}
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3"> <CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => ( {recentSessions.map((s) => (
<div <div
key={s.id} key={s.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" 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 flex-col gap-1 min-w-0 w-full"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-medium text-sm truncate"> <span className="min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled} {s.title ?? t.common.untitled}
</span> </span>
<span className="text-xs text-muted-foreground truncate"> <span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui"> <span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()} {(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "} </span>{" "}
@ -762,15 +761,15 @@ export default function SessionsPage() {
</span> </span>
{s.preview && ( {s.preview && (
<span className="text-xs text-muted-foreground/70 truncate"> <p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
{s.preview} {s.preview}
</span> </p>
)} )}
</div> </div>
<Badge <Badge
tone="outline" tone="outline"
className="text-[10px] shrink-0 self-start sm:self-center" className="shrink-0 self-start text-[10px] sm:self-center"
> >
<Database className="mr-1 h-3 w-3" /> <Database className="mr-1 h-3 w-3" />
{s.source ?? "local"} {s.source ?? "local"}
@ -795,7 +794,7 @@ export default function SessionsPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="flex flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => ( {filtered.map((s) => (
<SessionRow <SessionRow
key={s.id} key={s.id}

View file

@ -205,7 +205,7 @@ export default function SkillsPage() {
<div className="relative w-full min-w-0 sm:max-w-xs"> <div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
className="h-8 pl-8 pr-7 text-xs" className="h-8 rounded-none pl-8 pr-7 text-xs"
placeholder={t.common.search} placeholder={t.common.search}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@ -256,12 +256,7 @@ export default function SkillsPage() {
<div className="flex flex-col sm:flex-row sm:items-start gap-4"> <div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0"> <aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0"> <div className="sm:sticky sm:top-0">
<div <div className="flex flex-col rounded-none border border-border bg-muted/20">
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-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground"> <span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
@ -309,7 +304,7 @@ export default function SkillsPage() {
onClick={() => onClick={() =>
setActiveCategory(isActive ? null : key) setActiveCategory(isActive ? null : key)
} }
className="rounded-sm px-2 py-1 text-[11px]" className="rounded-none px-2 py-1 text-[11px]"
> >
<span className="flex-1 truncate">{name}</span> <span className="flex-1 truncate">{name}</span>
<span <span
@ -333,7 +328,7 @@ export default function SkillsPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isSearching ? ( {isSearching ? (
<Card> <Card className="rounded-none">
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
@ -372,7 +367,7 @@ export default function SkillsPage() {
</Card> </Card>
) : view === "skills" ? ( ) : view === "skills" ? (
/* Skills list */ /* Skills list */
<Card> <Card className="rounded-none">
<CardHeader className="py-3 px-4"> <CardHeader className="py-3 px-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
@ -417,7 +412,7 @@ export default function SkillsPage() {
/* Toolsets grid */ /* Toolsets grid */
<> <>
{filteredToolsets.length === 0 ? ( {filteredToolsets.length === 0 ? (
<Card> <Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch} {t.skills.noToolsetsMatch}
</CardContent> </CardContent>
@ -431,7 +426,7 @@ export default function SkillsPage() {
ts.name; ts.name;
return ( return (
<Card key={ts.name} className="relative"> <Card key={ts.name} className="relative rounded-none">
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" /> <TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
@ -536,7 +531,7 @@ function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
active={active} active={active}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"rounded-sm whitespace-nowrap px-2.5 py-1.5", "rounded-none whitespace-nowrap px-2.5 py-1.5",
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase", "font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
active && "bg-foreground/90 text-background hover:text-background", active && "bg-foreground/90 text-background hover:text-background",
)} )}

View file

@ -17,6 +17,7 @@ import type {
ThemeLayer, ThemeLayer,
ThemeLayout, ThemeLayout,
ThemeLayoutVariant, ThemeLayoutVariant,
ThemeListEntry,
ThemePalette, ThemePalette,
ThemeTypography, ThemeTypography,
} from "./types"; } from "./types";
@ -311,7 +312,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** All selectable themes (shown in the picker). Starts with just the /** All selectable themes (shown in the picker). Starts with just the
* built-ins; the API call below merges in user themes. */ * built-ins; the API call below merges in user themes. */
const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() => const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
Object.values(BUILTIN_THEMES).map((t) => ({ Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name, name: t.name,
label: t.label, label: t.label,
@ -429,15 +430,8 @@ const ThemeContext = createContext<ThemeContextValue>({
}); });
interface ThemeContextValue { interface ThemeContextValue {
availableThemes: ThemeSummary[]; availableThemes: ThemeListEntry[];
setTheme: (name: string) => void; setTheme: (name: string) => void;
theme: DashboardTheme; theme: DashboardTheme;
themeName: string; themeName: string;
} }
interface ThemeSummary {
description: string;
label: string;
name: string;
definition?: DashboardTheme;
}

View file

@ -1,3 +1,3 @@
export { ThemeProvider, useTheme } from "./context"; export { ThemeProvider, useTheme } from "./context";
export { BUILTIN_THEMES, defaultTheme } from "./presets"; export { BUILTIN_THEMES, defaultTheme } from "./presets";
export type { DashboardTheme, ThemeLayer, ThemeListResponse, ThemePalette } from "./types"; export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";