hermes-agent/web/src/App.tsx
Austin Pickett c9410b3462
feat(web): add collapsible sidebar for the dashboard (#33421)
* feat(web): add collapsible sidebar for the dashboard

The desktop sidebar can now be collapsed to an icon-only rail via a
toggle button in the sidebar header.  State is persisted in
localStorage so it survives page reloads.

When collapsed (lg+ only):
- Sidebar shrinks from w-64 to w-14 with a smooth width transition
- Nav items show only their icon with a native title tooltip
- Brand text, plugin headings, system actions, theme/language
  switchers, auth widget, and footer are hidden
- Mobile drawer behavior is unchanged (always full-width)

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

* fix(web): align sidebar tooltips to sidebar edge consistently

Tooltip left position now uses the sidebar's right edge instead of the
anchor element's right edge, so narrow anchors (theme/language switchers)
align with full-width anchors (nav links, system actions).

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

* feat(web): add tooltip animations, restore theme label, rename Sessions tab

- Sidebar tooltips now animate in with a subtle 120ms ease-out slide;
  subsequent tooltips within the same hover sequence appear instantly
  (no delay/animation) following Emil Kowalski's tooltip pattern
- Restore theme name label when sidebar is expanded
- Rename Sessions segment tab to "History" across all 16 locales

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

* fix(web): smooth sidebar collapse animation

- Remove icon centering on collapse; icons stay left-aligned at px-5
  so they don't jump during the width transition
- Text labels fade out with opacity transition instead of instant
  display:none, clipped naturally by overflow-hidden
- Slow collapse duration from 450ms to 600ms for a more relaxed feel
- Gateway dot always rendered with opacity toggle so it doesn't
  slide in from the right on collapse
- Pin gateway dot at fixed left offset (pl-[1.625rem]) to align
  with nav icons
- Align header toggle button with justify-center when collapsed
- Bottom switchers use items-start when collapsed to prevent reflow

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 23:58:41 -04:00

1175 lines
35 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
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,
PanelLeftClose,
PanelLeftOpen,
Puzzle,
RotateCw,
Settings,
Shield,
Sparkles,
Star,
Terminal,
Users,
Wrench,
X,
Zap,
} from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
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 ProfilesPage from "@/pages/ProfilesPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import ChatPage from "@/pages/ChatPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import type { Translations } from "@/i18n/types";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
function UnknownRouteFallback({ pluginsLoading }: { pluginsLoading: boolean }) {
if (pluginsLoading) {
// Render nothing during the plugin-load window — a spinner here would just flash.
return null;
}
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,
"/plugins": PluginsPage,
"/profiles": ProfilesPage,
"/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: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
{ 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,
Users,
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;
}
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
function partitionSidebarNav(
builtIn: NavItem[],
manifests: PluginManifest[],
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
const merged = buildNavItems(builtIn, manifests);
const builtinPaths = new Set(builtIn.map((i) => i.path));
const coreItems: NavItem[] = [];
const pluginItems: NavItem[] = [];
for (const item of merged) {
if (builtinPaths.has(item.path)) coreItems.push(item);
else pluginItems.push(item);
}
return { coreItems, pluginItems };
}
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 (m.tab.path === "/plugins") 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 (m.tab.path === "/plugins") 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;
}
const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed";
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 [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
} catch {
return false;
}
});
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
} catch { /* localStorage may be unavailable in private browsing */ }
return next;
});
}, []);
const isMobile = useBelowBreakpoint(1024);
const isDesktopCollapsed = collapsed && !isMobile;
const tooltipWarmRef = useRef(0);
const sidebarStatus = useSidebarStatus();
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
const embeddedChat = isDashboardEmbeddedChatEnabled();
// `dashboard.show_token_analytics` gates the Analytics nav item. The
// page itself remains reachable by URL (it renders an explanation when
// the flag is off — see AnalyticsPage), but hiding the nav entry avoids
// surfacing misleading token/cost numbers in the sidebar. Default off.
const [showTokenAnalytics, setShowTokenAnalytics] = useState(false);
useEffect(() => {
api
.getConfig()
.then((cfg) => {
const dash = (cfg?.dashboard ?? {}) as {
show_token_analytics?: unknown;
};
setShowTokenAnalytics(dash.show_token_analytics === true);
})
.catch(() => setShowTokenAnalytics(false));
}, []);
// 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(() => {
const base = embeddedChat
? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST]
: BUILTIN_NAV_REST;
return showTokenAnalytics
? base
: base.filter((n) => n.path !== "/analytics");
}, [embeddedChat, showTokenAnalytics]);
const sidebarNav = useMemo(
() => partitionSidebarNav(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="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
>
<SelectionSwitcher />
<Backdrop />
<PluginSlot name="backdrop" />
<header
className={cn(
"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",
)}
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-text-secondary 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-14 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 lg:overflow-hidden",
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
collapsed && "lg:w-14",
)}
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 gap-2",
"border-b border-current/20",
collapsed ? "lg:justify-center lg:px-0" : "px-4 justify-between",
)}
>
<div
className={cn(
"flex items-center gap-2",
collapsed && "lg:hidden",
)}
>
<PluginSlot name="header-left" />
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground uppercase"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
</div>
<Button
ghost
size="icon"
onClick={closeMobile}
aria-label={t.app.closeNavigation}
className="lg:hidden text-text-secondary hover:text-midground"
>
<X />
</Button>
<Button
ghost
size="icon"
onClick={toggleCollapsed}
aria-label={
collapsed ? t.common.expand : t.common.collapse
}
className="hidden lg:flex text-text-secondary hover:text-midground"
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</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">
{sidebarNav.coreItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
collapsed={isDesktopCollapsed}
item={item}
key={item.path}
t={t}
tooltipWarmRef={tooltipWarmRef}
/>
))}
</ul>
{sidebarNav.pluginItems.length > 0 && (
<div
aria-labelledby="hermes-sidebar-plugin-nav-heading"
className="flex flex-col border-t border-current/10 pb-2"
role="group"
>
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
isDesktopCollapsed && "lg:hidden",
)}
id="hermes-sidebar-plugin-nav-heading"
>
{t.app.pluginNavSection}
</span>
<ul className="flex flex-col">
{sidebarNav.pluginItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
collapsed={isDesktopCollapsed}
item={item}
key={item.path}
t={t}
tooltipWarmRef={tooltipWarmRef}
/>
))}
</ul>
</div>
)}
</nav>
<SidebarSystemActions
collapsed={isDesktopCollapsed}
onNavigate={closeMobile}
status={sidebarStatus}
tooltipWarmRef={tooltipWarmRef}
/>
<div
className={cn(
"flex shrink-0 items-center gap-2",
"px-3 py-2",
"border-t border-current/20",
isDesktopCollapsed
? "lg:flex-col lg:items-start lg:gap-3 lg:py-3"
: "justify-between",
)}
>
<div
className={cn(
"flex min-w-0 items-center gap-2",
isDesktopCollapsed && "lg:flex-col lg:items-start",
)}
>
<PluginSlot name="header-right" />
<SidebarIconWithTooltip
collapsed={isDesktopCollapsed}
label={t.theme?.switchTheme ?? "Switch theme"}
tooltipWarmRef={tooltipWarmRef}
>
<ThemeSwitcher collapsed={isDesktopCollapsed} dropUp />
</SidebarIconWithTooltip>
<SidebarIconWithTooltip
collapsed={isDesktopCollapsed}
label={t.language.switchTo}
tooltipWarmRef={tooltipWarmRef}
>
<LanguageSwitcher collapsed={isDesktopCollapsed} dropUp />
</SidebarIconWithTooltip>
</div>
</div>
<div
className={cn(
"flex shrink-0 flex-col",
isDesktopCollapsed && "lg:hidden",
)}
>
<AuthWidget />
<SidebarFooter status={sidebarStatus} />
</div>
</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-0 pt-1 sm:pt-2 lg:pt-4"
: "pt-2 sm:pt-4 lg:pt-6",
isDocsRoute && "min-h-0 flex-1",
)}
>
<PluginSlot name="pre-main" />
<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",
)}
>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</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 SidebarNavLink({
closeMobile,
collapsed,
item,
tooltipWarmRef,
t,
}: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
aria-label={collapsed ? navLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
className={({ isActive }) =>
cn(
"group/nav relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
isActive
? "text-midground"
: "text-text-secondary hover:text-midground",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span
className={cn(
"truncate transition-opacity duration-300",
collapsed ? "lg:opacity-0" : "lg:opacity-100",
)}
>
{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/nav:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
}
function SidebarSystemActions({
collapsed,
onNavigate,
status,
tooltipWarmRef,
}: SidebarSystemActionsProps) {
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-display text-xs tracking-[0.12em] text-text-tertiary",
collapsed && "lg:hidden",
)}
>
{t.app.system}
</span>
<div className={cn(collapsed && "lg:hidden")}>
<SidebarStatusStrip status={status} />
</div>
<GatewayDot collapsed={collapsed} status={status} tooltipWarmRef={tooltipWarmRef} />
<ul className="flex flex-col">
{items.map((item) => (
<SystemActionButton
key={item.action}
collapsed={collapsed}
disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))}
tooltipWarmRef={tooltipWarmRef}
isPending={pendingAction === item.action}
isRunning={activeAction === item.action && isRunning && pendingAction !== item.action}
item={item}
onClick={() => handleClick(item.action)}
/>
))}
</ul>
</div>
);
}
function SystemActionButton({
collapsed,
disabled,
isPending,
isRunning: isActionRunning,
item,
onClick,
tooltipWarmRef,
}: SystemActionButtonProps) {
const { icon: Icon, label, runningLabel, spin } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
return (
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<button
onClick={onClick}
disabled={disabled}
aria-busy={busy}
aria-label={collapsed ? displayLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
type="button"
className={cn(
"group/action relative flex w-full items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display text-xs tracking-[0.1em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
busy
? "text-midground"
: "text-text-secondary hover:text-midground",
"disabled:text-text-disabled disabled:cursor-not-allowed",
)}
>
{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={cn(
"truncate transition-opacity duration-300",
collapsed ? "lg:opacity-0" : "lg:opacity-100",
)}>
{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/action:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
}
function SidebarIconWithTooltip({
children,
collapsed,
label,
tooltipWarmRef,
}: SidebarIconWithTooltipProps) {
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
return (
<div
ref={ref}
className={cn(
"relative w-fit",
collapsed && "group/icon",
)}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
{children}
{collapsed && (
<span
aria-hidden
className="absolute inset-y-0 inset-x-[-0.375rem] bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/icon:opacity-5 hidden lg:block"
/>
)}
{collapsed && hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
}
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
const { t } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
const toneToColor: Record<string, string> = {
"text-success": "bg-success",
"text-warning": "bg-warning",
"text-destructive": "bg-destructive",
"text-muted-foreground": "bg-muted-foreground",
};
let color: string;
let label: string;
if (!status) {
color = "bg-midground/20";
label = t.status.gateway;
} else {
const gw = gatewayLine(status, t);
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
label = `${t.status.gateway} ${gw.label}`;
}
return (
<div
ref={ref}
className={cn(
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
)}
role="status"
aria-label={label}
tabIndex={collapsed ? 0 : -1}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
>
<span
aria-hidden
className={cn("h-1.5 w-1.5 rounded-full", color)}
/>
{hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
}
function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
const rect = anchor.getBoundingClientRect();
const sidebar = document.getElementById("app-sidebar");
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
useEffect(() => {
if (warmRef) warmRef.current = Date.now();
return () => {
if (warmRef) warmRef.current = Date.now();
};
}, [warmRef]);
return createPortal(
<span
className={cn(
"fixed z-[100] pointer-events-none",
"px-2 py-1",
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
)}
style={{
top: rect.top + rect.height / 2,
left: sidebarRight + 8,
transform: "translateY(-50%)",
opacity: isWarm ? 1 : undefined,
animation: isWarm ? "none" : "sidebar-tooltip-in 120ms ease-out",
}}
>
{label}
</span>,
document.body,
);
}
type TooltipWarmRef = React.RefObject<number>;
interface GatewayDotProps {
collapsed: boolean;
status: StatusResponse | null;
tooltipWarmRef: TooltipWarmRef;
}
interface NavItem {
icon: ComponentType<{ className?: string }>;
label: string;
labelKey?: string;
path: string;
}
interface SidebarIconWithTooltipProps {
children: ReactNode;
collapsed: boolean;
label: string;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarNavLinkProps {
closeMobile: () => void;
collapsed: boolean;
item: NavItem;
t: Translations;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarSystemActionsProps {
collapsed: boolean;
onNavigate: () => void;
status: StatusResponse | null;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarTooltipProps {
anchor: HTMLElement;
label: string;
warmRef?: TooltipWarmRef;
}
interface SystemActionButtonProps {
collapsed: boolean;
disabled: boolean;
isPending: boolean;
isRunning: boolean;
item: SystemActionItem;
onClick: () => void;
tooltipWarmRef: TooltipWarmRef;
}
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;
label: string;
runningLabel: string;
spin: boolean;
}