From 6fa1701bd3d9dd41923adc30fe55ec3f02c693ce Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 18 May 2026 15:20:31 -0400 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Cursor --- nix/web.nix | 2 +- web/index.html | 5 +- web/package-lock.json | 33 +++- web/package.json | 1 + web/src/App.tsx | 14 +- web/src/components/BottomPickSheet.tsx | 224 ++++++++++++++++++++++++ web/src/components/LanguageSwitcher.tsx | 161 ++++++++++++----- web/src/components/ThemeSwitcher.tsx | 172 +++++++++++------- web/src/contexts/PageHeaderProvider.tsx | 53 ++++-- web/src/hooks/useBelowBreakpoint.ts | 19 ++ web/src/i18n/context.tsx | 39 +++-- web/src/main.tsx | 1 + web/src/pages/AnalyticsPage.tsx | 2 +- web/src/pages/ConfigPage.tsx | 10 +- web/src/pages/CronPage.tsx | 2 +- web/src/pages/EnvPage.tsx | 7 +- web/src/pages/LogsPage.tsx | 28 ++- web/src/pages/ModelsPage.tsx | 51 +++--- web/src/pages/PluginsPage.tsx | 58 +++--- web/src/pages/ProfilesPage.tsx | 42 ++++- web/src/pages/SessionsPage.tsx | 115 ++++++------ web/src/pages/SkillsPage.tsx | 21 +-- web/src/themes/context.tsx | 12 +- web/src/themes/index.ts | 2 +- 24 files changed, 779 insertions(+), 295 deletions(-) create mode 100644 web/src/components/BottomPickSheet.tsx create mode 100644 web/src/hooks/useBelowBreakpoint.ts diff --git a/nix/web.nix b/nix/web.nix index a5793dff7ad..f335bb9fa9c 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI="; + hash = "sha256-H98reD4N++WroZOQ9NFrKtC5aiHj6KqaYDzUOiZA2bE="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; diff --git a/web/index.html b/web/index.html index e420ce6dbad..fe7cda519d2 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,10 @@ - + Hermes Agent - Dashboard diff --git a/web/package-lock.json b/web/package-lock.json index 7f987c5a1d2..149aa24e422 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -19,6 +19,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", @@ -76,6 +77,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1124,6 +1126,7 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", + "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1776,6 +1779,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2481,6 +2485,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2490,6 +2495,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2500,6 +2506,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2564,6 +2571,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2892,6 +2900,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3044,6 +3053,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3551,6 +3561,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3864,6 +3875,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4143,6 +4155,12 @@ "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4242,7 +4260,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "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": { "version": "4.0.0", @@ -4548,6 +4567,7 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4986,6 +5006,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5113,6 +5134,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5184,6 +5206,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5203,6 +5226,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5562,7 +5586,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5627,6 +5652,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5725,6 +5751,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "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", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5861,6 +5889,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 50456076b64..04cbe290b37 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 71a97113c24..987252ce0bb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -424,8 +424,8 @@ export default function App() {
-
+
@@ -588,8 +588,8 @@ export default function App() { "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "px-3 sm:px-6", isChatRoute - ? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4" - : "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8", + ? "pb-0 pt-1 sm:pt-2 lg:pt-4" + : "pt-2 sm:pt-4 lg:pt-6", isDocsRoute && "min-h-0 flex-1", )} > @@ -597,6 +597,8 @@ export default function App() {
| null>(null); + const sheetRef = useRef(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) => { + 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) => { + 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) => { + 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( +
+ - {open && ( -
setOpen(false)} + open={open} + title={sheetTitle} > - {allLocales.map(([code, meta]) => { - const selected = code === locale; - return ( - - ); - })} +
+ +
+ + )} + + {open && !useMobileSheet && ( +
+
)}
); } + +function LanguageSwitcherOptions({ + allLocales, + locale, + setLocale, + setOpen, +}: LanguageSwitcherOptionsProps) { + return ( + <> + {allLocales.map(([code, meta]) => { + const selected = code === locale; + + return ( + + ); + })} + + ); +} + +function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) { + return ( + + ); +} + +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; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 90a3d11ebdb..17e0ae3d6da 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Palette, Check } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { BottomPickSheet } from "@/components/BottomPickSheet"; import { Typography } from "@/components/NouiTypography"; +import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint"; import { BUILTIN_THEMES, useTheme } from "@/themes"; -import type { DashboardTheme } from "@/themes"; +import type { DashboardTheme, ThemeListEntry } from "@/themes"; import { useI18n } from "@/i18n"; 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 * `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) { const { themeName, availableThemes, setTheme } = useTheme(); const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); + const narrowViewport = useBelowBreakpoint(640); + const useMobileSheet = Boolean(dropUp && narrowViewport); const close = useCallback(() => setOpen(false), []); useEffect(() => { 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) => { if ( wrapperRef.current && @@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { close(); } }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") close(); - }; document.addEventListener("mousedown", onMouseDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onMouseDown); - document.removeEventListener("keydown", onKey); - }; - }, [open, close]); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open, close, useMobileSheet]); const current = availableThemes.find((th) => th.name === themeName); const label = current?.label ?? themeName; + const sheetTitle = t.theme?.title ?? "Theme"; return (
@@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { - {open && ( + {useMobileSheet && ( + +
+ +
+
+ )} + + {open && !useMobileSheet && (
- {t.theme?.title ?? "Theme"} + {sheetTitle}
- {availableThemes.map((th) => { - const isActive = th.name === themeName; - const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; - - return ( - { - setTheme(th.name); - close(); - }} - className="gap-3" - > - {paletteTheme ? ( - - ) : ( - - )} - -
- - {th.label} - - {th.description && ( - - {th.description} - - )} -
- - -
- ); - })} +
)}
); } +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 ( + { + setTheme(th.name); + close(); + }} + role="option" + > + {paletteTheme ? ( + + ) : ( + + )} + +
+ + {th.label} + + {th.description && ( + + {th.description} + + )} +
+ + +
+ ); + })} + + ); +} + function ThemeSwatch({ theme }: { theme: DashboardTheme }) { const { background, midground, warmGlow } = theme.palette; return ( @@ -168,6 +213,13 @@ function PlaceholderSwatch() { ); } +interface ThemeSwitcherOptionsProps { + availableThemes: ThemeListEntry[]; + close: () => void; + setTheme: (name: string) => void; + themeName: string; +} + interface ThemeSwitcherProps { dropUp?: boolean; } diff --git a/web/src/contexts/PageHeaderProvider.tsx b/web/src/contexts/PageHeaderProvider.tsx index 4184ecb3d98..9fdd6215e34 100644 --- a/web/src/contexts/PageHeaderProvider.tsx +++ b/web/src/contexts/PageHeaderProvider.tsx @@ -35,6 +35,9 @@ export function PageHeaderProvider({ const displayTitle = titleOverride ?? defaultTitle; 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( () => ({ @@ -51,37 +54,65 @@ export function PageHeaderProvider({
-
+

{displayTitle}

- {afterTitle} + {afterTitle ? ( +
+ {afterTitle} +
+ ) : null}
{end ? (
{end} @@ -93,6 +124,8 @@ export function PageHeaderProvider({
+ 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; +} diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index 7d6fecf5c9b..e31ffa65050 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -38,25 +38,26 @@ const TRANSLATIONS: Record = { // Display metadata for the language picker — endonym (native name) so users // 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 -// LanguageSwitcher and any future settings page can share the same list. -export const LOCALE_META: Record = { - en: { name: "English", flag: "🇬🇧" }, - zh: { name: "简体中文", flag: "🇨🇳" }, - "zh-hant": { name: "繁體中文", flag: "🇹🇼" }, - ja: { name: "日本語", flag: "🇯🇵" }, - de: { name: "Deutsch", flag: "🇩🇪" }, - es: { name: "Español", flag: "🇪🇸" }, - fr: { name: "Français", flag: "🇫🇷" }, - tr: { name: "Türkçe", flag: "🇹🇷" }, - uk: { name: "Українська", flag: "🇺🇦" }, - af: { name: "Afrikaans", flag: "🇿🇦" }, - ko: { name: "한국어", flag: "🇰🇷" }, - it: { name: "Italiano", flag: "🇮🇹" }, - ga: { name: "Gaeilge", flag: "🇮🇪" }, - pt: { name: "Português", flag: "🇵🇹" }, - ru: { name: "Русский", flag: "🇷🇺" }, - hu: { name: "Magyar", flag: "🇭🇺" }, +// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning. +// Exposed as a constant so the LanguageSwitcher and any future settings page +// can share the same list. +export const LOCALE_META: Record = { + en: { name: "English", flagCountryCode: "gb" }, + zh: { name: "简体中文", flagCountryCode: "cn" }, + "zh-hant": { name: "繁體中文", flagCountryCode: "tw" }, + ja: { name: "日本語", flagCountryCode: "jp" }, + de: { name: "Deutsch", flagCountryCode: "de" }, + es: { name: "Español", flagCountryCode: "es" }, + fr: { name: "Français", flagCountryCode: "fr" }, + tr: { name: "Türkçe", flagCountryCode: "tr" }, + uk: { name: "Українська", flagCountryCode: "ua" }, + af: { name: "Afrikaans", flagCountryCode: "za" }, + ko: { name: "한국어", flagCountryCode: "kr" }, + it: { name: "Italiano", flagCountryCode: "it" }, + ga: { name: "Gaeilge", flagCountryCode: "ie" }, + pt: { name: "Português", flagCountryCode: "pt" }, + ru: { name: "Русский", flagCountryCode: "ru" }, + hu: { name: "Magyar", flagCountryCode: "hu" }, }; const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]; diff --git a/web/src/main.tsx b/web/src/main.tsx index e0d00fdf636..c727f0e3f72 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import "flag-icons/css/flag-icons.min.css"; import "./index.css"; import App from "./App"; import { SystemActionsProvider } from "./contexts/SystemActions"; diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 4896e760636..c97af1deed2 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -439,7 +439,7 @@ export default function AnalyticsPage() { ); setEnd( showTokens === false ? null : ( -
+
{PERIODS.map((p) => ( diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index da9afe9236e..bfe1be3ec7a 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -46,6 +46,12 @@ const LINE_COLORS: Record = { const toOptions = (values: readonly T[]) => 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() { const [file, setFile] = useState<(typeof FILES)[number]>("agent"); const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL"); @@ -87,7 +93,7 @@ export default function LogsPage() { , ); setEnd( -
+
+
- + - + - + - + setLineCount(Number(v) as (typeof LINE_COUNTS)[number]) @@ -190,7 +200,7 @@ export default function LogsPage() {
- + @@ -206,7 +216,7 @@ export default function LogsPage() {
{lines.length === 0 && !loading && (

diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index f09104d4241..134ff3eab38 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -336,7 +336,9 @@ function ModelCard({ )?.task ?? null; return ( - +

@@ -666,22 +668,20 @@ function ModelSettingsPanel({ ).length ?? 0; return ( - - -
-
- - Model Settings - - applies to new sessions - -
+ + +
+ + Model Settings + + applies to new sessions +
- + {/* Main row */} -
+
@@ -698,14 +698,14 @@ function ModelSettingsPanel({
{/* Auxiliary tasks summary + open modal */} -
+
@@ -723,7 +723,7 @@ function ModelSettingsPanel({ size="sm" outlined onClick={() => setAuxModalOpen(true)} - className="text-xs" + className="shrink-0 self-start text-xs sm:self-center" > Configure @@ -827,7 +827,7 @@ export default function ModelsPage() { , ); setEnd( -
+
{PERIODS.map((p) => ( , +
+ +
, ); return () => setEnd(null); }, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]); @@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
+
-
+ {row.name} -
+ + {t.pluginsPage.sourceBadge}: {row.source} + - {row.name} + v{row.version || "—"} - - {t.pluginsPage.sourceBadge}: {row.source} - + {row.runtime_status} - - v{row.version || "—"} - - {row.runtime_status} - - {row.auth_required ? ( - {t.pluginsPage.authRequired} - ) : null} -
- - {row.description ? ( - -

- {row.description} -

+ {row.auth_required ? ( + {t.pluginsPage.authRequired} ) : null}
@@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
+ {row.description ? ( +

+ {row.description} +

+ ) : null} + {dm?.slots?.length ? (

diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 933f3f3e1d3..af00c96f6d6 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react"; +import spinners from "unicode-animations"; import { H2 } from "@/components/NouiTypography"; import { api } 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. 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 ( + + {frames[frameIndex]} + + ); +} + export default function ProfilesPage() { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -199,8 +229,14 @@ export default function ProfilesPage() { if (loading) { return ( -

-
+
+ {t.common.loading} + +
); } @@ -318,7 +354,7 @@ export default function ProfilesPage() { const isEditingSoul = editingSoulFor === p.name; return ( - +
{isRenaming ? ( diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index dd2ad6b2314..f7d24e9d729 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) { parts.push(snippet.slice(last)); } return ( -

+

{parts}

); @@ -296,24 +296,24 @@ function SessionRow({ return (
-
-
- -
-
-
+
+ +
+
+
+
{hasTitle ? session.title @@ -322,71 +322,70 @@ function SessionRow({ : t.sessions.untitledSession} {session.is_active && ( - + {t.common.live} )}
-
- +
+ {(session.model ?? t.common.unknown).split("/").pop()} · - + {session.message_count} {t.common.msgs} {session.tool_call_count > 0 && ( <> · - + {session.tool_call_count} {t.common.tools} )} · - {timeAgo(session.last_active)} + {timeAgo(session.last_active)}
- {snippet && }
-
- -
- - {session.source ?? "local"} - - {resumeInChatEnabled && ( + {snippet && } +
+ + {session.source ?? "local"} + + {resumeInChatEnabled && ( + + )} - )} - +
{isExpanded && ( -
+
{loading && (
@@ -624,7 +623,7 @@ export default function SessionsPage() { } return ( -
+
@@ -732,28 +731,28 @@ export default function SessionsPage() { )} {recentSessions.length > 0 && ( - - -
- - + + +
+ + {t.status.recentSessions}
- + {recentSessions.map((s) => (
-
- +
+ {s.title ?? t.common.untitled} - + {(s.model ?? t.common.unknown).split("/").pop()} {" "} @@ -762,15 +761,15 @@ export default function SessionsPage() { {s.preview && ( - +

{s.preview} - +

)}
{s.source ?? "local"} @@ -795,7 +794,7 @@ export default function SessionsPage() {
) : ( <> -
+
{filtered.map((s) => ( setSearch(e.target.value)} @@ -256,12 +256,7 @@ export default function SkillsPage() {