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;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI=";
hash = "sha256-H98reD4N++WroZOQ9NFrKtC5aiHj6KqaYDzUOiZA2bE=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };

View file

@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<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>
</head>
<body>

33
web/package-lock.json generated
View file

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

View file

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

View file

@ -424,8 +424,8 @@ export default function App() {
<header
className={cn(
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
"flex items-center gap-2 px-3",
"lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
"flex items-center gap-2 px-4 py-2",
"border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm",
)}
@ -469,7 +469,7 @@ export default function App() {
<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">
<aside
id="app-sidebar"
@ -575,7 +575,7 @@ export default function App() {
<div className="flex min-w-0 items-center gap-2">
<PluginSlot name="header-right" />
<ThemeSwitcher dropUp />
<LanguageSwitcher />
<LanguageSwitcher dropUp />
</div>
</div>
@ -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() {
<div
className={cn(
"w-full min-w-0",
!isChatRoute &&
"pb-[calc(2rem+env(safe-area-inset-bottom,0px))] lg:pb-8",
(isDocsRoute || isChatRoute) &&
"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 { Button } from "@nous-research/ui/ui/components/button";
import { BottomPickSheet } from "@/components/BottomPickSheet";
import { Typography } from "@/components/NouiTypography";
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
import { useI18n } from "@/i18n/context";
import { LOCALE_META } from "@/i18n";
import type { Locale } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* 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
* (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 [open, setOpen] = useState(false);
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(() => {
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) {
if (!containerRef.current) return;
@ -28,20 +50,14 @@ export function LanguageSwitcher() {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, useMobileSheet]);
const current = LOCALE_META[locale];
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
const sheetTitle = t.language.switchTo;
return (
<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"
>
<span className="inline-flex items-center gap-1.5">
<span className="text-base leading-none">{current.flag}</span>
<LocaleFlagIcon countryCode={current.flagCountryCode} />
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
@ -65,36 +81,103 @@ export function LanguageSwitcher() {
</span>
</Button>
{open && (
<div
role="listbox"
aria-label={t.language.switchTo}
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"
{useMobileSheet && (
<BottomPickSheet
backdropDismissLabel={t.common.close}
onClose={() => setOpen(false)}
open={open}
title={sheetTitle}
>
{allLocales.map(([code, meta]) => {
const selected = code === locale;
return (
<button
key={code}
role="option"
aria-selected={selected}
onClick={() => {
setLocale(code);
setOpen(false);
}}
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")
}
>
<span className="text-base leading-none">{meta.flag}</span>
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
</button>
);
})}
<div aria-label={sheetTitle} role="listbox">
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div
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",
dropUp ? "bottom-full mb-1" : "top-full mt-1",
)}
role="listbox"
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</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 { 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<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative">
@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
</span>
</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
role="listbox"
aria-label={t.theme?.title ?? "Theme"}
aria-label={sheetTitle}
className={cn(
"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",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
)}
role="listbox"
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
>
{t.theme?.title ?? "Theme"}
{sheetTitle}
</Typography>
</div>
{availableThemes.map((th) => {
const isActive = th.name === themeName;
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
return (
<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>
);
})}
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</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 }) {
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;
}

View file

@ -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({
<header
className={cn(
"z-1 w-full shrink-0",
"box-border h-14 min-h-14",
"border-b border-current/20",
"box-border border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm",
"overflow-hidden",
"sm:min-h-0",
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
"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"
>
<div
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
? "flex-row 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
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" }}
>
{displayTitle}
</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>
{end ? (
<div
className={cn(
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
isChatRoute ? "w-auto shrink-0" : "w-full",
"flex min-w-0 sm:max-w-md sm:flex-1",
isChatRoute
? "w-auto shrink-0 justify-end"
: "w-full justify-start sm:justify-end",
)}
>
{end}
@ -93,6 +124,8 @@ export function PageHeaderProvider({
<main
className={cn(
"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
? "overflow-hidden"
: "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
// 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<Locale, { name: string; flag: string }> = {
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<Locale, { name: string; flagCountryCode: string }> = {
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[];

View file

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

View file

@ -439,7 +439,7 @@ export default function AnalyticsPage() {
);
setEnd(
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">
{PERIODS.map((p) => (
<Button

View file

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

View file

@ -370,7 +370,7 @@ export default function CronPage() {
return (
<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 items-center gap-2 mb-1">
<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" });
};
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) => (
<button
key={s.id}
type="button"
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}
</button>

View file

@ -46,6 +46,12 @@ const LINE_COLORS: Record<string, string> = {
const toOptions = <T extends string>(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() {
</span>,
);
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">
<Switch
checked={autoRefresh}
@ -145,39 +151,43 @@ export default function LogsPage() {
}, [autoRefresh, fetchLogs]);
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" />
<div
role="toolbar"
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
className={segmentedClass}
value={file}
onChange={setFile}
options={toOptions(FILES)}
/>
</FilterGroup>
<FilterGroup label={t.logs.level}>
<FilterGroup label={t.logs.level} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={level}
onChange={setLevel}
options={toOptions(LEVELS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.component}>
<FilterGroup label={t.logs.component} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={component}
onChange={setComponent}
options={toOptions(COMPONENTS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.lines}>
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
<Segmented
className={segmentedClass}
value={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
@ -190,7 +200,7 @@ export default function LogsPage() {
</FilterGroup>
</div>
<Card>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
@ -206,7 +216,7 @@ export default function LogsPage() {
<div
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 && (
<p className="text-muted-foreground text-center py-8">

View file

@ -336,7 +336,9 @@ function ModelCard({
)?.task ?? null;
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">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
@ -666,22 +668,20 @@ function ModelSettingsPanel({
).length ?? 0;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle>
<span className="text-[10px] text-muted-foreground">
applies to new sessions
</span>
</div>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0 pb-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle>
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
applies to new sessions
</span>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<CardContent className="min-w-0 space-y-3 pt-3">
{/* 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="flex items-center gap-2 mb-0.5">
<Star className="h-3 w-3 text-primary" />
@ -698,14 +698,14 @@ function ModelSettingsPanel({
<Button
size="sm"
onClick={() => setPicker({ kind: "main" })}
className="text-xs"
className="shrink-0 self-start text-xs sm:self-center"
>
Change
</Button>
</div>
{/* 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="flex items-center gap-2 mb-0.5">
<Cpu className="h-3 w-3 text-muted-foreground" />
@ -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
</Button>
@ -827,7 +827,7 @@ export default function ModelsPage() {
</span>,
);
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">
{PERIODS.map((p) => (
<Button
@ -864,10 +864,10 @@ export default function ModelsPage() {
}, [load]);
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" />
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
<ModelSettingsPanel
aux={aux}
refreshKey={saveKey}
@ -875,10 +875,12 @@ export default function ModelsPage() {
/>
{data && (
<Card>
<CardContent className="py-6">
<Stats
items={
<Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="min-w-0 py-6">
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
<Stats
className="min-w-0"
items={
showTokens
? [
{
@ -920,6 +922,7 @@ export default function ModelsPage() {
]
}
/>
</div>
{!showTokens && (
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
Token & cost analytics are hidden because the local counts
@ -953,7 +956,7 @@ export default function ModelsPage() {
{data && (
<>
{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) => (
<ModelCard
key={`${m.model}:${m.provider}`}

View file

@ -60,16 +60,18 @@ export default function PluginsPage() {
useEffect(() => {
setEnd(
<Button
ghost
size="sm"
className="shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>,
<div className="flex w-full min-w-0 justify-start">
<Button
ghost
size="sm"
className="w-max max-w-full shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>
</div>,
);
return () => setEnd(null);
}, [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 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">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
<Badge tone="outline">v{row.version || "—"}</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>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
</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 ? (
<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 { 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 (
<span
aria-hidden
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
>
{frames[frameIndex]}
</span>
);
}
export default function ProfilesPage() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [loading, setLoading] = useState(true);
@ -199,8 +229,14 @@ export default function ProfilesPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<div
aria-busy="true"
aria-live="polite"
className="flex items-center justify-center py-24"
>
<span className="sr-only">{t.common.loading}</span>
<ProfilesLoadingSpinner />
</div>
);
}
@ -318,7 +354,7 @@ export default function ProfilesPage() {
const isEditingSoul = editingSoulFor === p.name;
return (
<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 items-center gap-2 mb-1 flex-wrap">
{isRenaming ? (

View file

@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
parts.push(snippet.slice(last));
}
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}
</p>
);
@ -296,24 +296,24 @@ function SessionRow({
return (
<div
className={`border overflow-hidden transition-colors ${
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
>
<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}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`shrink-0 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<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
? session.title
@ -322,71 +322,70 @@ function SessionRow({
: t.sessions.untitledSession}
</span>
{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" />
{t.common.live}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-[120px] sm:max-w-[180px]">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
{(session.model ?? t.common.unknown).split("/").pop()}
</span>
<span className="text-border">&#183;</span>
<span>
<span className="shrink-0">
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>
<span className="shrink-0">
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span>{timeAgo(session.last_active)}</span>
<span className="shrink-0">{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge tone="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
{snippet && <SnippetHighlight snippet={snippet} />}
<div className="flex flex-wrap items-center gap-2">
<Badge tone="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
onDelete();
}}
>
<Play />
<Trash2 />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</div>
</div>
</div>
{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 && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
@ -624,7 +623,7 @@ export default function SessionsPage() {
}
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" />
<Toast toast={toast} />
@ -732,28 +731,28 @@ export default function SessionsPage() {
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
<Card className="min-w-0 max-w-full overflow-hidden">
<CardHeader className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
<CardTitle className="min-w-0 truncate text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<CardContent className="grid min-w-0 gap-3">
{recentSessions.map((s) => (
<div
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">
<span className="font-medium text-sm truncate">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="min-w-0 truncate text-sm font-medium">
{s.title ?? t.common.untitled}
</span>
<span className="text-xs text-muted-foreground truncate">
<span className="min-w-0 break-words text-xs text-muted-foreground">
<span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
@ -762,15 +761,15 @@ export default function SessionsPage() {
</span>
{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}
</span>
</p>
)}
</div>
<Badge
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" />
{s.source ?? "local"}
@ -795,7 +794,7 @@ export default function SessionsPage() {
</div>
) : (
<>
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
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">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<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}
value={search}
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">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">
<div
className={`
flex flex-col
border border-border bg-muted/20
`}
>
<div className="flex flex-col rounded-none border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 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={() =>
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
@ -333,7 +328,7 @@ export default function SkillsPage() {
<div className="flex-1 min-w-0">
{isSearching ? (
<Card>
<Card className="rounded-none">
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
@ -372,7 +367,7 @@ export default function SkillsPage() {
</Card>
) : view === "skills" ? (
/* Skills list */
<Card>
<Card className="rounded-none">
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
@ -417,7 +412,7 @@ export default function SkillsPage() {
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
<Card>
<Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</CardContent>
@ -431,7 +426,7 @@ export default function SkillsPage() {
ts.name;
return (
<Card key={ts.name} className="relative">
<Card key={ts.name} className="relative rounded-none">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<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}
onClick={onClick}
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",
active && "bg-foreground/90 text-background hover:text-background",
)}

View file

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

View file

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