fix: use grid/cell components

This commit is contained in:
Austin Pickett 2026-04-19 15:21:57 -04:00
parent 923539a46b
commit 60fd4b7d16
12 changed files with 434 additions and 181 deletions

View file

@ -4,7 +4,7 @@
<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" />
<title>Hermes Agent</title> <title>Hermes Agent - Dashboard</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

9
web/package-lock.json generated
View file

@ -29,6 +29,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1" "vite": "^7.3.1"
@ -3840,6 +3841,14 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"devOptional": true,
"license": "MIT",
"peer": true
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View file

@ -34,6 +34,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1" "vite": "^7.3.1"

View file

@ -1,12 +1,30 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Routes, Route, NavLink, Navigate } from "react-router-dom"; import { Routes, Route, NavLink, Navigate } from "react-router-dom";
import { import {
Activity, BarChart3, Clock, FileText, KeyRound, Activity,
MessageSquare, Package, Settings, Puzzle, BarChart3,
Sparkles, Terminal, Globe, Database, Shield, Clock,
Wrench, Zap, Heart, Star, Code, Eye, FileText,
KeyRound,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
Shield,
Wrench,
Zap,
Heart,
Star,
Code,
Eye,
} from "lucide-react"; } from "lucide-react";
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher"; import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop"; import { Backdrop } from "@/components/Backdrop";
import StatusPage from "@/pages/StatusPage"; import StatusPage from "@/pages/StatusPage";
@ -25,8 +43,18 @@ import type { RegisteredPlugin } from "@/plugins";
const BUILTIN_NAV: NavItem[] = [ const BUILTIN_NAV: NavItem[] = [
{ path: "/", labelKey: "status", label: "Status", icon: Activity }, { path: "/", labelKey: "status", label: "Status", icon: Activity },
{ path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare }, {
{ path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 }, path: "/sessions",
labelKey: "sessions",
label: "Sessions",
icon: MessageSquare,
},
{
path: "/analytics",
labelKey: "analytics",
label: "Analytics",
icon: BarChart3,
},
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
@ -37,17 +65,38 @@ const BUILTIN_NAV: NavItem[] = [
// Plugins can reference any of these by name in their manifest — keeps bundle // Plugins can reference any of these by name in their manifest — keeps bundle
// size sane vs. importing the full lucide-react set. // size sane vs. importing the full lucide-react set.
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = { const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Activity, BarChart3, Clock, FileText, KeyRound, Activity,
MessageSquare, Package, Settings, Puzzle, BarChart3,
Sparkles, Terminal, Globe, Database, Shield, Clock,
Wrench, Zap, Heart, Star, Code, Eye, FileText,
KeyRound,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
Shield,
Wrench,
Zap,
Heart,
Star,
Code,
Eye,
}; };
function resolveIcon(name: string): React.ComponentType<{ className?: string }> { function resolveIcon(
name: string,
): React.ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle; return ICON_MAP[name] ?? Puzzle;
} }
function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] { function buildNavItems(
builtIn: NavItem[],
plugins: RegisteredPlugin[],
): NavItem[] {
const items = [...builtIn]; const items = [...builtIn];
for (const { manifest } of plugins) { for (const { manifest } of plugins) {
@ -97,69 +146,86 @@ export default function App() {
"bg-background-base/90 backdrop-blur-sm", "bg-background-base/90 backdrop-blur-sm",
)} )}
> >
<div className="mx-auto flex h-12 max-w-[1600px] items-stretch"> <div className="mx-auto flex h-12 max-w-[1600px]">
<div className="flex items-center border-r border-current/20 px-3 sm:px-5 shrink-0"> <div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<span <Grid
className="font-sans font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground" className="h-full !border-t-0 !border-b-0"
style={{ mixBlendMode: "plus-lighter" }} style={{
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
}}
> >
Hermes <Cell className="flex items-center !p-0 !px-3 sm:!px-5">
<br /> <Typography
Agent className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
</span> style={{ mixBlendMode: "plus-lighter" }}
</div> >
Hermes
<br />
Agent
</Typography>
</Cell>
<nav className="flex items-stretch overflow-x-auto scrollbar-none"> {navItems.map(({ path, label, labelKey, icon: Icon }) => (
{navItems.map(({ path, label, labelKey, icon: Icon }) => ( <Cell key={path} className="relative !p-0">
<NavLink <NavLink
key={path} to={path}
to={path} end={path === "/"}
end={path === "/"} className={({ isActive }) =>
className={({ isActive }) => cn(
cn( "group relative flex h-full w-full items-center gap-1.5",
"group relative inline-flex items-center gap-1.5 shrink-0", "px-2.5 sm:px-4 py-2",
"border-r border-current/20 px-2.5 sm:px-4 py-2", "font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]", "whitespace-nowrap transition-colors cursor-pointer",
"whitespace-nowrap transition-colors cursor-pointer", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", isActive
isActive ? "text-midground"
? "text-midground" : "opacity-60 hover:opacity-100",
: "opacity-60 hover:opacity-100", )
) }
} >
> {({ isActive }) => (
{({ isActive }) => ( <>
<> <Icon className="h-3.5 w-3.5 shrink-0" />
<Icon className="h-3.5 w-3.5 shrink-0" /> <span className="hidden sm:inline">
<span className="hidden sm:inline"> {labelKey
{labelKey ? (t.app.nav as Record<string, string>)[labelKey] ?? label : label} ? ((t.app.nav as Record<string, string>)[
</span> labelKey
] ?? label)
: label}
</span>
<span <span
aria-hidden aria-hidden
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5" className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/> />
{isActive && ( {isActive && (
<span <span
aria-hidden aria-hidden
className="absolute bottom-0 left-0 right-0 h-px bg-midground" className="absolute bottom-0 left-0 right-0 h-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }} style={{ mixBlendMode: "plus-lighter" }}
/> />
)}
</>
)} )}
</> </NavLink>
)} </Cell>
</NavLink> ))}
))} </Grid>
</nav>
<div className="ml-auto flex items-center gap-2 border-l border-current/20 px-2 sm:px-4">
<ThemeSwitcher />
<LanguageSwitcher />
<span className="hidden sm:inline font-mondwest text-[0.7rem] tracking-[0.15em] opacity-50">
{t.app.webUi}
</span>
</div> </div>
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
<ThemeSwitcher />
<LanguageSwitcher />
<Typography
mondwest
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
>
{t.app.webUi}
</Typography>
</Cell>
</Grid>
</div> </div>
</header> </header>
@ -187,17 +253,25 @@ export default function App() {
</main> </main>
<footer className="relative z-2 border-t border-current/20"> <footer className="relative z-2 border-t border-current/20">
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-3 sm:px-6 py-3"> <Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0">
<span className="font-mondwest text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"> <Cell className="flex items-center !px-3 sm:!px-6 !py-3">
{t.app.footer.name} <Typography
</span> mondwest
<span className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
className="font-mondwest text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground" >
style={{ mixBlendMode: "plus-lighter" }} {t.app.footer.name}
> </Typography>
{t.app.footer.org} </Cell>
</span> <Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
</div> <Typography
mondwest
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
</Cell>
</Grid>
</footer> </footer>
</div> </div>
); );

View file

@ -1,3 +1,4 @@
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { useI18n } from "@/i18n/context"; import { useI18n } from "@/i18n/context";
/** /**
@ -19,9 +20,12 @@ export function LanguageSwitcher() {
> >
{/* Show the *current* language's flag — tooltip advertises the click action */} {/* Show the *current* language's flag — tooltip advertises the click action */}
<span className="text-base leading-none">{locale === "en" ? "🇬🇧" : "🇨🇳"}</span> <span className="text-base leading-none">{locale === "en" ? "🇬🇧" : "🇨🇳"}</span>
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]"> <Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{locale === "en" ? "EN" : "中文"} {locale === "en" ? "EN" : "中文"}
</span> </Typography>
</button> </button>
); );
} }

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -12,9 +13,21 @@ interface Props {
onError: (msg: string) => void; onError: (msg: string) => void;
} }
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error"; type Phase =
| "idle"
| "starting"
| "awaiting_user"
| "submitting"
| "polling"
| "approved"
| "error";
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) { export function OAuthLoginModal({
provider,
onClose,
onSuccess,
onError,
}: Props) {
const [phase, setPhase] = useState<Phase>("starting"); const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null); const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState(""); const [pkceCode, setPkceCode] = useState("");
@ -81,13 +94,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
if (!isMounted.current) return; if (!isMounted.current) return;
if (resp.status === "approved") { if (resp.status === "approved") {
setPhase("approved"); setPhase("approved");
if (pollTimer.current !== null) window.clearInterval(pollTimer.current); if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
onSuccess(`${provider.name} connected`); onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500); window.setTimeout(() => isMounted.current && onClose(), 1500);
} else if (resp.status !== "pending") { } else if (resp.status !== "pending") {
setPhase("error"); setPhase("error");
setErrorMsg(resp.error_message || `Login ${resp.status}`); setErrorMsg(resp.error_message || `Login ${resp.status}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current); if (pollTimer.current !== null)
window.clearInterval(pollTimer.current);
} }
} catch (e) { } catch (e) {
if (!isMounted.current) return; if (!isMounted.current) return;
@ -107,7 +122,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setPhase("submitting"); setPhase("submitting");
setErrorMsg(null); setErrorMsg(null);
try { try {
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim()); const resp = await api.submitOAuthCode(
provider.id,
start.session_id,
pkceCode.trim(),
);
if (!isMounted.current) return; if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") { if (resp.ok && resp.status === "approved") {
setPhase("approved"); setPhase("approved");
@ -175,14 +194,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
</button> </button>
<div className="p-6 flex flex-col gap-4"> <div className="p-6 flex flex-col gap-4">
<div> <div>
<h2 id="oauth-modal-title" className="font-mondwest text-base tracking-wider uppercase"> <Typography
as="h2"
mondwest
id="oauth-modal-title"
className="text-base tracking-wider uppercase"
>
{t.oauth.connect} {provider.name} {t.oauth.connect} {provider.name}
</h2> </Typography>
{secondsLeft !== null && phase !== "approved" && phase !== "error" && ( {secondsLeft !== null &&
<p className="text-xs text-muted-foreground mt-1"> phase !== "approved" &&
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))} phase !== "error" && (
</p> <p className="text-xs text-muted-foreground mt-1">
)} {t.oauth.sessionExpires.replace(
"{time}",
fmtTime(secondsLeft),
)}
</p>
)}
</div> </div>
{/* ── starting ───────────────────────────────────── */} {/* ── starting ───────────────────────────────────── */}
@ -211,7 +240,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
/> />
<div className="flex items-center gap-2 justify-between"> <div className="flex items-center gap-2 justify-between">
<a <a
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url} href={
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
.auth_url
}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
@ -219,7 +251,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
{t.oauth.reOpenAuth} {t.oauth.reOpenAuth}
</a> </a>
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm"> <Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
size="sm"
>
{t.oauth.submitCode} {t.oauth.submitCode}
</Button> </Button>
</div> </div>
@ -243,23 +279,46 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
</p> </p>
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4"> <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
<code className="font-mono-ui text-2xl tracking-widest text-foreground"> <code className="font-mono-ui text-2xl tracking-widest text-foreground">
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code} {
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
</code> </code>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() =>
handleCopyUserCode( handleCopyUserCode(
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code, (
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code,
) )
} }
className="text-xs" className="text-xs"
> >
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />} {codeCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button> </Button>
</div> </div>
<a <a
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url} href={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).verification_url
}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
@ -302,21 +361,36 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
setStart(null); setStart(null);
setPkceCode(""); setPkceCode("");
setPhase("starting"); setPhase("starting");
api.startOAuthLogin(provider.id).then((resp) => { api
if (!isMounted.current) return; .startOAuthLogin(provider.id)
setStart(resp); .then((resp) => {
setSecondsLeft(resp.expires_in); if (!isMounted.current) return;
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); setStart(resp);
if (resp.flow === "pkce") { setSecondsLeft(resp.expires_in);
window.open(resp.auth_url, "_blank", "noopener,noreferrer"); setPhase(
} else { resp.flow === "device_code"
window.open(resp.verification_url, "_blank", "noopener,noreferrer"); ? "polling"
} : "awaiting_user",
}).catch((e) => { );
if (!isMounted.current) return; if (resp.flow === "pkce") {
setPhase("error"); window.open(
setErrorMsg(`${t.common.retry} failed: ${e}`); resp.auth_url,
}); "_blank",
"noopener,noreferrer",
);
} else {
window.open(
resp.verification_url,
"_blank",
"noopener,noreferrer",
);
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`${t.common.retry} failed: ${e}`);
});
}} }}
> >
{t.common.retry} {t.common.retry}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Palette, Check } from "lucide-react"; import { Palette, Check } from "lucide-react";
import { Typography } from "@nous-research/ui/ui/components/typography/index";
import { BUILTIN_THEMES, useTheme } from "@/themes"; import { BUILTIN_THEMES, useTheme } from "@/themes";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -56,9 +57,12 @@ export function ThemeSwitcher() {
aria-haspopup="listbox" aria-haspopup="listbox"
> >
<Palette className="h-3.5 w-3.5" /> <Palette className="h-3.5 w-3.5" />
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]"> <Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{label} {label}
</span> </Typography>
</button> </button>
{open && ( {open && (
@ -72,9 +76,12 @@ export function ThemeSwitcher() {
)} )}
> >
<div className="border-b border-current/20 px-3 py-2"> <div className="border-b border-current/20 px-3 py-2">
<span className="font-mondwest text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"> <Typography
mondwest
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
>
{t.theme?.title ?? "Theme"} {t.theme?.title ?? "Theme"}
</span> </Typography>
</div> </div>
{availableThemes.map((th) => { {availableThemes.map((th) => {
@ -100,13 +107,16 @@ export function ThemeSwitcher() {
{preset ? <ThemeSwatch theme={preset.name} /> : <PlaceholderSwatch />} {preset ? <ThemeSwatch theme={preset.name} /> : <PlaceholderSwatch />}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mondwest text-[0.75rem] tracking-wide uppercase"> <Typography
mondwest
className="truncate text-[0.75rem] tracking-wide uppercase"
>
{th.label} {th.label}
</span> </Typography>
{th.description && ( {th.description && (
<span className="truncate font-sans text-[0.65rem] normal-case tracking-normal text-midground/50"> <Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
{th.description} {th.description}
</span> </Typography>
)} )}
</div> </div>

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api"; import type { CronJob } from "@/lib/api";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
@ -195,10 +196,10 @@ export default function CronPage() {
{/* Jobs list */} {/* Jobs list */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2"> <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length}) {t.cron.scheduledJobs} ({jobs.length})
</h2> </H2>
{jobs.length === 0 && ( {jobs.length === 0 && (
<Card> <Card>

View file

@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw, ChevronRight } from "lucide-react"; import { FileText, RefreshCw, ChevronRight } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -104,7 +105,7 @@ export default function LogsPage() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" /> <FileText className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.logs.title}</h1> <H2 variant="sm">{t.logs.title}</H2>
{loading && ( {loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)} )}

View file

@ -13,6 +13,7 @@ import {
Hash, Hash,
X, X,
} from "lucide-react"; } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api"; import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
import { timeAgo } from "@/lib/utils"; import { timeAgo } from "@/lib/utils";
@ -383,7 +384,7 @@ export default function SessionsPage() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" /> <MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.sessions.title}</h1> <H2 variant="sm">{t.sessions.title}</H2>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{total} {total}
</Badge> </Badge>

View file

@ -15,6 +15,7 @@ import {
Code, Code,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
@ -193,7 +194,7 @@ export default function SkillsPage() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" /> <Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">{t.skills.title}</h1> <H2 variant="sm">{t.skills.title}</H2>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))} {t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
</span> </span>

View file

@ -9,6 +9,7 @@ import {
Wifi, Wifi,
WifiOff, WifiOff,
} from "lucide-react"; } from "lucide-react";
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api"; import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { timeAgo, isoTimeAgo } from "@/lib/utils"; import { timeAgo, isoTimeAgo } from "@/lib/utils";
@ -23,8 +24,14 @@ export default function StatusPage() {
useEffect(() => { useEffect(() => {
const load = () => { const load = () => {
api.getStatus().then(setStatus).catch(() => {}); api
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {}); .getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((resp) => setSessions(resp.sessions))
.catch(() => {});
}; };
load(); load();
const interval = setInterval(load, 5000); const interval = setInterval(load, 5000);
@ -39,13 +46,19 @@ export default function StatusPage() {
); );
} }
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = { const PLATFORM_STATE_BADGE: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
> = {
connected: { variant: "success", label: t.status.connected }, connected: { variant: "success", label: t.status.connected },
disconnected: { variant: "warning", label: t.status.disconnected }, disconnected: { variant: "warning", label: t.status.disconnected },
fatal: { variant: "destructive", label: t.status.error }, fatal: { variant: "destructive", label: t.status.error },
}; };
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = { const GATEWAY_STATE_DISPLAY: Record<
string,
{ badge: "success" | "warning" | "destructive" | "outline"; label: string }
> = {
running: { badge: "success", label: t.status.running }, running: { badge: "success", label: t.status.running },
starting: { badge: "warning", label: t.status.starting }, starting: { badge: "warning", label: t.status.starting },
startup_failed: { badge: "destructive", label: t.status.failed }, startup_failed: { badge: "destructive", label: t.status.failed },
@ -53,15 +66,19 @@ export default function StatusPage() {
}; };
function gatewayValue(): string { function gatewayValue(): string {
if (status!.gateway_running && status!.gateway_health_url) return status!.gateway_health_url; if (status!.gateway_running && status!.gateway_health_url)
if (status!.gateway_running && status!.gateway_pid) return `${t.status.pid} ${status!.gateway_pid}`; return status!.gateway_health_url;
if (status!.gateway_running && status!.gateway_pid)
return `${t.status.pid} ${status!.gateway_pid}`;
if (status!.gateway_running) return t.status.runningRemote; if (status!.gateway_running) return t.status.runningRemote;
if (status!.gateway_state === "startup_failed") return t.status.startFailed; if (status!.gateway_state === "startup_failed") return t.status.startFailed;
return t.status.notRunning; return t.status.notRunning;
} }
function gatewayBadge() { function gatewayBadge() {
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null; const info = status!.gateway_state
? GATEWAY_STATE_DISPLAY[status!.gateway_state]
: null;
if (info) return info; if (info) return info;
return status!.gateway_running return status!.gateway_running
? { badge: "success" as const, label: t.status.running } ? { badge: "success" as const, label: t.status.running }
@ -88,9 +105,14 @@ export default function StatusPage() {
{ {
icon: Activity, icon: Activity,
label: t.status.activeSessions, label: t.status.activeSessions,
value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning, value:
status.active_sessions > 0
? `${status.active_sessions} ${t.status.running.toLowerCase()}`
: t.status.noneRunning,
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off, badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline", badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as
| "success"
| "outline",
}, },
]; ];
@ -106,9 +128,14 @@ export default function StatusPage() {
detail: status.gateway_exit_reason ?? undefined, detail: status.gateway_exit_reason ?? undefined,
}); });
} }
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected"); const failedPlatforms = platforms.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatforms) { for (const [name, info] of failedPlatforms) {
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected; const stateLabel =
info.state === "fatal"
? t.status.platformError
: t.status.platformDisconnected;
alerts.push({ alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`, message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
detail: info.error_message ?? undefined, detail: info.error_message ?? undefined,
@ -117,7 +144,6 @@ export default function StatusPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Alert banner — breaks grid monotony for critical states */}
{alerts.length > 0 && ( {alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4"> <div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -125,9 +151,13 @@ export default function StatusPage() {
<div className="flex flex-col gap-2 min-w-0"> <div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => ( {alerts.map((alert, i) => (
<div key={i}> <div key={i}>
<p className="text-sm font-medium text-destructive">{alert.message}</p> <p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && ( {alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p> <p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)} )}
</div> </div>
))} ))}
@ -136,32 +166,41 @@ export default function StatusPage() {
</div> </div>
)} )}
<div className="grid gap-4 sm:grid-cols-3"> <Grid className="border-b lg:!grid-cols-3">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => ( {items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<Card key={label} className="min-w-0 overflow-hidden"> <Cell
<CardHeader className="flex flex-row items-center justify-between pb-2"> key={label}
className="flex min-w-0 flex-col gap-2 overflow-hidden"
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">{label}</CardTitle> <CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader> </div>
<CardContent> <div
<div className="text-2xl font-bold font-mondwest truncate" title={value}>{value}</div> className="truncate text-2xl font-bold font-mondwest"
title={value}
>
{value}
</div>
{badgeText && ( {badgeText && (
<Badge variant={badgeVariant} className="mt-2"> <Badge variant={badgeVariant} className="self-start">
{badgeVariant === "success" && ( {badgeVariant === "success" && (
<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" />
)} )}
{badgeText} {badgeText}
</Badge> </Badge>
)} )}
</CardContent> </Cell>
</Card>
))} ))}
</div> </Grid>
{platforms.length > 0 && ( {platforms.length > 0 && (
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} /> <PlatformsCard
platforms={platforms}
platformStateBadge={PLATFORM_STATE_BADGE}
/>
)} )}
{activeSessions.length > 0 && ( {activeSessions.length > 0 && (
@ -169,7 +208,9 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" /> <Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">{t.status.activeSessions}</CardTitle> <CardTitle className="text-base">
{t.status.activeSessions}
</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@ -181,7 +222,9 @@ export default function StatusPage() {
> >
<div className="flex flex-col gap-1 min-w-0 w-full"> <div className="flex flex-col gap-1 min-w-0 w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span> <span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<Badge variant="success" className="text-[10px] shrink-0"> <Badge variant="success" className="text-[10px] shrink-0">
<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" />
@ -190,7 +233,11 @@ export default function StatusPage() {
</div> </div>
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)} <span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span> </span>
</div> </div>
</div> </div>
@ -204,7 +251,9 @@ export default function StatusPage() {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" /> <Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.status.recentSessions}</CardTitle> <CardTitle className="text-base">
{t.status.recentSessions}
</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@ -215,10 +264,16 @@ export default function StatusPage() {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
> >
<div className="flex flex-col gap-1 min-w-0 w-full"> <div className="flex flex-col gap-1 min-w-0 w-full">
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span> <span className="font-medium text-sm truncate">
{s.title ?? t.common.untitled}
</span>
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)} <span className="font-mono-ui">
{(s.model ?? t.common.unknown).split("/").pop()}
</span>{" "}
· {s.message_count} {t.common.msgs} ·{" "}
{timeAgo(s.last_active)}
</span> </span>
{s.preview && ( {s.preview && (
@ -228,7 +283,10 @@ export default function StatusPage() {
)} )}
</div> </div>
<Badge variant="outline" className="text-[10px] shrink-0 self-start sm:self-center"> <Badge
variant="outline"
className="text-[10px] shrink-0 self-start 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"}
</Badge> </Badge>
@ -249,7 +307,9 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" /> <Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle> <CardTitle className="text-base">
{t.status.connectedPlatforms}
</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@ -259,7 +319,12 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
variant: "outline" as const, variant: "outline" as const,
label: info.state, label: info.state,
}; };
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff; const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return ( return (
<div <div
@ -267,19 +332,25 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full" className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
> >
<div className="flex items-center gap-3 min-w-0 w-full"> <div className="flex items-center gap-3 min-w-0 w-full">
<IconComponent className={`h-4 w-4 shrink-0 ${ <IconComponent
info.state === "connected" className={`h-4 w-4 shrink-0 ${
? "text-success" info.state === "connected"
: info.state === "fatal" ? "text-success"
? "text-destructive" : info.state === "fatal"
: "text-warning" ? "text-destructive"
}`} /> : "text-warning"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">{name}</span> <span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && ( {info.error_message && (
<span className="text-xs text-destructive">{info.error_message}</span> <span className="text-xs text-destructive">
{info.error_message}
</span>
)} )}
{info.updated_at && ( {info.updated_at && (
@ -290,7 +361,10 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
</div> </div>
</div> </div>
<Badge variant={display.variant} className="shrink-0 self-start sm:self-center"> <Badge
variant={display.variant}
className="shrink-0 self-start sm:self-center"
>
{display.variant === "success" && ( {display.variant === "success" && (
<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" />
)} )}
@ -306,5 +380,8 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
interface PlatformsCardProps { interface PlatformsCardProps {
platforms: [string, PlatformStatus][]; platforms: [string, PlatformStatus][];
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>; platformStateBadge: Record<
string,
{ variant: "success" | "warning" | "destructive"; label: string }
>;
} }