mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
- New /models page in left nav (after Analytics) - New /api/analytics/models endpoint with per-model token/cost/session breakdown, cache read/reasoning tokens, tool calls, avg tokens/session, and capabilities from models.dev (vision/tools/reasoning/context window) - Model cards with stacked token distribution bar, capability badges, provider badges, cost info, and relative time - Summary stats bar (models used, total tokens, est. cost, sessions) - Period selector (7d/30d/90d) with refresh - i18n support (en + zh)
739 lines
22 KiB
TypeScript
739 lines
22 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ComponentType,
|
|
type ReactNode,
|
|
} from "react";
|
|
import {
|
|
Routes,
|
|
Route,
|
|
NavLink,
|
|
Navigate,
|
|
useLocation,
|
|
useNavigate,
|
|
} from "react-router-dom";
|
|
import {
|
|
Activity,
|
|
BarChart3,
|
|
BookOpen,
|
|
Clock,
|
|
Code,
|
|
Cpu,
|
|
Database,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Globe,
|
|
Heart,
|
|
KeyRound,
|
|
Menu,
|
|
MessageSquare,
|
|
Package,
|
|
Puzzle,
|
|
RotateCw,
|
|
Settings,
|
|
Shield,
|
|
Sparkles,
|
|
Star,
|
|
Terminal,
|
|
Wrench,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import {
|
|
Button,
|
|
ListItem,
|
|
SelectionSwitcher,
|
|
Spinner,
|
|
Typography,
|
|
} from "@nous-research/ui";
|
|
import { cn } from "@/lib/utils";
|
|
import { Backdrop } from "@/components/Backdrop";
|
|
import { SidebarFooter } from "@/components/SidebarFooter";
|
|
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
|
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
|
import { useSystemActions } from "@/contexts/useSystemActions";
|
|
import type { SystemAction } from "@/contexts/system-actions-context";
|
|
import ConfigPage from "@/pages/ConfigPage";
|
|
import DocsPage from "@/pages/DocsPage";
|
|
import EnvPage from "@/pages/EnvPage";
|
|
import SessionsPage from "@/pages/SessionsPage";
|
|
import LogsPage from "@/pages/LogsPage";
|
|
import AnalyticsPage from "@/pages/AnalyticsPage";
|
|
import ModelsPage from "@/pages/ModelsPage";
|
|
import CronPage from "@/pages/CronPage";
|
|
import SkillsPage from "@/pages/SkillsPage";
|
|
import ChatPage from "@/pages/ChatPage";
|
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
|
import { useI18n } from "@/i18n";
|
|
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
|
import type { PluginManifest } from "@/plugins";
|
|
import { useTheme } from "@/themes";
|
|
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
|
|
|
function RootRedirect() {
|
|
return <Navigate to="/sessions" replace />;
|
|
}
|
|
|
|
const CHAT_NAV_ITEM: NavItem = {
|
|
path: "/chat",
|
|
labelKey: "chat",
|
|
label: "Chat",
|
|
icon: Terminal,
|
|
};
|
|
|
|
/**
|
|
* Built-in routes except /chat. Chat is rendered persistently (outside
|
|
* <Routes>) when embedded — see the persistent chat host block rendered
|
|
* inline near the bottom of this file — so the PTY child, WebSocket,
|
|
* and xterm instance survive when the user visits another tab and comes
|
|
* back. A `display:none` toggle hides the terminal without unmounting.
|
|
* Routing still owns the URL so /chat deep-links, browser back/forward,
|
|
* and nav highlight keep working.
|
|
*/
|
|
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|
"/": RootRedirect,
|
|
"/sessions": SessionsPage,
|
|
"/analytics": AnalyticsPage,
|
|
"/models": ModelsPage,
|
|
"/logs": LogsPage,
|
|
"/cron": CronPage,
|
|
"/skills": SkillsPage,
|
|
"/config": ConfigPage,
|
|
"/env": EnvPage,
|
|
"/docs": DocsPage,
|
|
};
|
|
|
|
// Route placeholder for /chat. The persistent ChatPage host (rendered
|
|
// outside <Routes> when embedded chat is on) paints on top; this empty
|
|
// element just claims the path so the `*` catch-all redirect doesn't
|
|
// fire when the user navigates to /chat.
|
|
function ChatRouteSink() {
|
|
return null;
|
|
}
|
|
|
|
const BUILTIN_NAV_REST: NavItem[] = [
|
|
{
|
|
path: "/sessions",
|
|
labelKey: "sessions",
|
|
label: "Sessions",
|
|
icon: MessageSquare,
|
|
},
|
|
{
|
|
path: "/analytics",
|
|
labelKey: "analytics",
|
|
label: "Analytics",
|
|
icon: BarChart3,
|
|
},
|
|
{
|
|
path: "/models",
|
|
labelKey: "models",
|
|
label: "Models",
|
|
icon: Cpu,
|
|
},
|
|
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
|
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
|
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
|
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
|
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
|
{
|
|
path: "/docs",
|
|
labelKey: "documentation",
|
|
label: "Documentation",
|
|
icon: BookOpen,
|
|
},
|
|
];
|
|
|
|
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
|
Activity,
|
|
BarChart3,
|
|
Clock,
|
|
Cpu,
|
|
FileText,
|
|
KeyRound,
|
|
MessageSquare,
|
|
Package,
|
|
Settings,
|
|
Puzzle,
|
|
Sparkles,
|
|
Terminal,
|
|
Globe,
|
|
Database,
|
|
Shield,
|
|
Wrench,
|
|
Zap,
|
|
Heart,
|
|
Star,
|
|
Code,
|
|
Eye,
|
|
};
|
|
|
|
function resolveIcon(name: string): ComponentType<{ className?: string }> {
|
|
return ICON_MAP[name] ?? Puzzle;
|
|
}
|
|
|
|
function buildNavItems(
|
|
builtIn: NavItem[],
|
|
manifests: PluginManifest[],
|
|
): NavItem[] {
|
|
const items = [...builtIn];
|
|
|
|
for (const manifest of manifests) {
|
|
if (manifest.tab.override) continue;
|
|
if (manifest.tab.hidden) continue;
|
|
|
|
const pluginItem: NavItem = {
|
|
path: manifest.tab.path,
|
|
label: manifest.label,
|
|
icon: resolveIcon(manifest.icon),
|
|
};
|
|
|
|
const pos = manifest.tab.position ?? "end";
|
|
if (pos === "end") {
|
|
items.push(pluginItem);
|
|
} else if (pos.startsWith("after:")) {
|
|
const target = "/" + pos.slice(6);
|
|
const idx = items.findIndex((i) => i.path === target);
|
|
items.splice(idx >= 0 ? idx + 1 : items.length, 0, pluginItem);
|
|
} else if (pos.startsWith("before:")) {
|
|
const target = "/" + pos.slice(7);
|
|
const idx = items.findIndex((i) => i.path === target);
|
|
items.splice(idx >= 0 ? idx : items.length, 0, pluginItem);
|
|
} else {
|
|
items.push(pluginItem);
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function buildRoutes(
|
|
builtinRoutes: Record<string, ComponentType>,
|
|
manifests: PluginManifest[],
|
|
): Array<{
|
|
key: string;
|
|
path: string;
|
|
element: ReactNode;
|
|
}> {
|
|
const byOverride = new Map<string, PluginManifest>();
|
|
const addons: PluginManifest[] = [];
|
|
|
|
for (const m of manifests) {
|
|
if (m.tab.override) {
|
|
byOverride.set(m.tab.override, m);
|
|
} else {
|
|
addons.push(m);
|
|
}
|
|
}
|
|
|
|
const routes: Array<{
|
|
key: string;
|
|
path: string;
|
|
element: ReactNode;
|
|
}> = [];
|
|
|
|
for (const [path, Component] of Object.entries(builtinRoutes)) {
|
|
const om = byOverride.get(path);
|
|
if (om) {
|
|
routes.push({
|
|
key: `override:${om.name}`,
|
|
path,
|
|
element: <PluginPage name={om.name} />,
|
|
});
|
|
} else {
|
|
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
|
|
}
|
|
}
|
|
|
|
for (const m of addons) {
|
|
if (m.tab.hidden) continue;
|
|
if (builtinRoutes[m.tab.path]) continue;
|
|
routes.push({
|
|
key: `plugin:${m.name}`,
|
|
path: m.tab.path,
|
|
element: <PluginPage name={m.name} />,
|
|
});
|
|
}
|
|
|
|
for (const m of manifests) {
|
|
if (!m.tab.hidden) continue;
|
|
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
|
routes.push({
|
|
key: `plugin:hidden:${m.name}`,
|
|
path: m.tab.path,
|
|
element: <PluginPage name={m.name} />,
|
|
});
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
export default function App() {
|
|
const { t } = useI18n();
|
|
const { pathname } = useLocation();
|
|
const { manifests, loading: pluginsLoading } = usePlugins();
|
|
const { theme } = useTheme();
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
|
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
|
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
|
const isChatRoute = normalizedPath === "/chat";
|
|
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
|
|
|
// A plugin can replace the built-in /chat page via `tab.override: "/chat"`
|
|
// in its manifest. When one does, `buildRoutes` already swaps the route
|
|
// element for <PluginPage /> — but we also have to suppress the
|
|
// persistent ChatPage host below, or the plugin's page and the built-in
|
|
// terminal would paint on top of each other. The override is niche
|
|
// (nothing ships overriding /chat today) but it's an advertised
|
|
// extension point, so preserve the pre-persistence contract: when a
|
|
// plugin owns /chat, the built-in chat UI is entirely absent.
|
|
//
|
|
// Waiting on `pluginsLoading` is load-bearing: manifests arrive
|
|
// asynchronously from /api/dashboard/plugins, so on initial render
|
|
// `chatOverriddenByPlugin` is always false. Without the loading
|
|
// gate, the persistent host would mount, spawn a PTY, and THEN get
|
|
// yanked out from under the user when the plugin's manifest resolves
|
|
// — killing the session mid-paint. Delaying host mount by the
|
|
// plugin-load window (typically <50ms, worst case 2s safety timeout)
|
|
// is the cheaper trade-off.
|
|
const chatOverriddenByPlugin = useMemo(
|
|
() => manifests.some((m) => m.tab.override === "/chat"),
|
|
[manifests],
|
|
);
|
|
|
|
const builtinRoutes = useMemo(
|
|
() => ({
|
|
...BUILTIN_ROUTES_CORE,
|
|
...(embeddedChat ? { "/chat": ChatRouteSink } : {}),
|
|
}),
|
|
[embeddedChat],
|
|
);
|
|
|
|
const builtinNav = useMemo(
|
|
() =>
|
|
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
|
|
[embeddedChat],
|
|
);
|
|
|
|
const navItems = useMemo(
|
|
() => buildNavItems(builtinNav, manifests),
|
|
[builtinNav, manifests],
|
|
);
|
|
const routes = useMemo(
|
|
() => buildRoutes(builtinRoutes, manifests),
|
|
[builtinRoutes, manifests],
|
|
);
|
|
const pluginTabMeta = useMemo(
|
|
() =>
|
|
manifests
|
|
.filter((m) => !m.tab.hidden)
|
|
.map((m) => ({
|
|
path: m.tab.override ?? m.tab.path,
|
|
label: m.label,
|
|
})),
|
|
[manifests],
|
|
);
|
|
|
|
const layoutVariant = theme.layoutVariant ?? "standard";
|
|
|
|
useEffect(() => {
|
|
if (!mobileOpen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setMobileOpen(false);
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.removeEventListener("keydown", onKey);
|
|
document.body.style.overflow = prevOverflow;
|
|
};
|
|
}, [mobileOpen]);
|
|
|
|
useEffect(() => {
|
|
const mql = window.matchMedia("(min-width: 1024px)");
|
|
const onChange = (e: MediaQueryListEvent) => {
|
|
if (e.matches) setMobileOpen(false);
|
|
};
|
|
mql.addEventListener("change", onChange);
|
|
return () => mql.removeEventListener("change", onChange);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
data-layout-variant={layoutVariant}
|
|
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
|
|
>
|
|
<SelectionSwitcher />
|
|
<Backdrop />
|
|
<PluginSlot name="backdrop" />
|
|
|
|
<header
|
|
className={cn(
|
|
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
|
|
"flex items-center gap-2 px-3",
|
|
"border-b border-current/20",
|
|
"bg-background-base/90 backdrop-blur-sm",
|
|
)}
|
|
style={{
|
|
background: "var(--component-header-background)",
|
|
borderImage: "var(--component-header-border-image)",
|
|
clipPath: "var(--component-header-clip-path)",
|
|
}}
|
|
>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label={t.app.openNavigation}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="app-sidebar"
|
|
className="text-midground/70 hover:text-midground"
|
|
>
|
|
<Menu />
|
|
</Button>
|
|
|
|
<Typography
|
|
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
>
|
|
{t.app.brand}
|
|
</Typography>
|
|
</header>
|
|
|
|
{mobileOpen && (
|
|
<Button
|
|
ghost
|
|
aria-label={t.app.closeNavigation}
|
|
onClick={closeMobile}
|
|
className={cn(
|
|
"lg:hidden fixed inset-0 z-40 p-0 block",
|
|
"bg-black/60 backdrop-blur-sm",
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<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">
|
|
<aside
|
|
id="app-sidebar"
|
|
aria-label={t.app.navigation}
|
|
className={cn(
|
|
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
|
"border-r border-current/20",
|
|
"bg-background-base/95 backdrop-blur-sm",
|
|
"transition-transform duration-200 ease-out",
|
|
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
|
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
|
)}
|
|
style={{
|
|
background: "var(--component-sidebar-background)",
|
|
clipPath: "var(--component-sidebar-clip-path)",
|
|
borderImage: "var(--component-sidebar-border-image)",
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-14 shrink-0 items-center justify-between gap-2",
|
|
"border-b border-current/20",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<PluginSlot name="header-left" />
|
|
|
|
<Typography
|
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
>
|
|
Hermes
|
|
<br />
|
|
Agent
|
|
</Typography>
|
|
</div>
|
|
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={closeMobile}
|
|
aria-label={t.app.closeNavigation}
|
|
className="lg:hidden text-midground/70 hover:text-midground"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
|
|
<nav
|
|
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
|
|
aria-label={t.app.navigation}
|
|
>
|
|
<ul className="flex flex-col">
|
|
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
|
|
const navLabel = labelKey
|
|
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
|
: label;
|
|
return (
|
|
<li key={path}>
|
|
<NavLink
|
|
to={path}
|
|
end={path === "/sessions"}
|
|
onClick={closeMobile}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3",
|
|
"px-5 py-2.5",
|
|
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
|
"whitespace-nowrap transition-colors cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
isActive
|
|
? "text-midground"
|
|
: "opacity-60 hover:opacity-100",
|
|
)
|
|
}
|
|
style={{
|
|
clipPath: "var(--component-tab-clip-path)",
|
|
}}
|
|
>
|
|
{({ isActive }) => (
|
|
<>
|
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">{navLabel}</span>
|
|
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
|
/>
|
|
|
|
{isActive && (
|
|
<span
|
|
aria-hidden
|
|
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
<SidebarSystemActions onNavigate={closeMobile} />
|
|
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 items-center justify-between gap-2",
|
|
"px-3 py-2",
|
|
"border-t border-current/20",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<PluginSlot name="header-right" />
|
|
<ThemeSwitcher dropUp />
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
<SidebarFooter />
|
|
</aside>
|
|
|
|
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
|
<div
|
|
className={cn(
|
|
"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",
|
|
isDocsRoute && "min-h-0 flex-1",
|
|
)}
|
|
>
|
|
<PluginSlot name="pre-main" />
|
|
<div
|
|
className={cn(
|
|
"w-full min-w-0",
|
|
(isDocsRoute || isChatRoute) &&
|
|
"min-h-0 flex flex-1 flex-col",
|
|
)}
|
|
>
|
|
<Routes>
|
|
{routes.map(({ key, path, element }) => (
|
|
<Route key={key} path={path} element={element} />
|
|
))}
|
|
<Route
|
|
path="*"
|
|
element={<Navigate to="/sessions" replace />}
|
|
/>
|
|
</Routes>
|
|
|
|
{embeddedChat &&
|
|
!chatOverriddenByPlugin &&
|
|
(pluginsLoading ? (
|
|
isChatRoute ? (
|
|
<div
|
|
className="flex min-h-0 min-w-0 flex-1 items-center justify-center"
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Spinner />
|
|
<span>Loading chat…</span>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
) : (
|
|
<div
|
|
data-chat-active={isChatRoute ? "true" : "false"}
|
|
className={cn(
|
|
"min-h-0 min-w-0",
|
|
isChatRoute ? "flex flex-1 flex-col" : "hidden",
|
|
)}
|
|
aria-hidden={!isChatRoute}
|
|
>
|
|
<ChatPage isActive={isChatRoute} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<PluginSlot name="post-main" />
|
|
</div>
|
|
</PageHeaderProvider>
|
|
</div>
|
|
</div>
|
|
|
|
<PluginSlot name="overlay" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
|
const { t } = useI18n();
|
|
const navigate = useNavigate();
|
|
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
|
useSystemActions();
|
|
|
|
const items: SystemActionItem[] = [
|
|
{
|
|
action: "restart",
|
|
icon: RotateCw,
|
|
label: t.status.restartGateway,
|
|
runningLabel: t.status.restartingGateway,
|
|
spin: true,
|
|
},
|
|
{
|
|
action: "update",
|
|
icon: Download,
|
|
label: t.status.updateHermes,
|
|
runningLabel: t.status.updatingHermes,
|
|
spin: false,
|
|
},
|
|
];
|
|
|
|
const handleClick = (action: SystemAction) => {
|
|
if (isBusy) return;
|
|
void runAction(action);
|
|
navigate("/sessions");
|
|
onNavigate();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"shrink-0 flex flex-col",
|
|
"border-t border-current/10",
|
|
"py-1",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"px-5 pt-0.5 pb-0.5",
|
|
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
|
)}
|
|
>
|
|
{t.app.system}
|
|
</span>
|
|
|
|
<SidebarStatusStrip />
|
|
|
|
<ul className="flex flex-col">
|
|
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
|
const isPending = pendingAction === action;
|
|
const isActionRunning =
|
|
activeAction === action && isRunning && !isPending;
|
|
const busy = isPending || isActionRunning;
|
|
const displayLabel = isActionRunning ? runningLabel : label;
|
|
const disabled = isBusy && !busy;
|
|
|
|
return (
|
|
<li key={action}>
|
|
<ListItem
|
|
onClick={() => handleClick(action)}
|
|
disabled={disabled}
|
|
aria-busy={busy}
|
|
active={busy}
|
|
className={cn(
|
|
"gap-3 px-5 py-1.5 whitespace-nowrap",
|
|
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
|
"transition-opacity",
|
|
busy
|
|
? "text-midground opacity-100"
|
|
: "opacity-60 hover:opacity-100",
|
|
"disabled:opacity-30",
|
|
)}
|
|
>
|
|
{isPending ? (
|
|
<Spinner className="shrink-0 text-[0.875rem]" />
|
|
) : isActionRunning && spin ? (
|
|
<Spinner className="shrink-0 text-[0.875rem]" />
|
|
) : (
|
|
<Icon
|
|
className={cn(
|
|
"h-3.5 w-3.5 shrink-0",
|
|
isActionRunning && !spin && "animate-pulse",
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<span className="truncate">{displayLabel}</span>
|
|
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
|
/>
|
|
|
|
{busy && (
|
|
<span
|
|
aria-hidden
|
|
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
/>
|
|
)}
|
|
</ListItem>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface NavItem {
|
|
icon: ComponentType<{ className?: string }>;
|
|
label: string;
|
|
labelKey?: string;
|
|
path: string;
|
|
}
|
|
|
|
interface SystemActionItem {
|
|
action: SystemAction;
|
|
icon: ComponentType<{ className?: string }>;
|
|
label: string;
|
|
runningLabel: string;
|
|
spin: boolean;
|
|
}
|