mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
The dashboard's Chat tab (hermes dashboard --tui) lost its session whenever the user navigated to another tab and came back. React Router unmounted ChatPage on path change, which ran the cleanup function, closed the PTY WebSocket, and terminated the underlying TUI child - so the next mount generated a fresh channel id, spawned a new PTY, and started a brand-new conversation. Rather than rebuild the destroyed state (session id capture + resume via HERMES_TUI_RESUME would reload history from disk but drop in-flight tool state, scrollback, and picker position), keep the component tree alive. * Pull ChatPage out of Routes into a sibling always-mounted host that toggles visibility via display:none keyed off the current route. A tiny ChatRouteSink still claims /chat so the catch-all redirect does not fire. * xterm instance, WebSocket, PTY child, and TUI/agent state all survive; returning to /chat shows the exact conversation the user left. * Respect plugin `/chat` overrides: if a plugin manifest declares `tab.override: "/chat"`, the Routes tree already swaps the element for <PluginPage /> — we additionally suppress the persistent host so the two don't paint on top of each other. Preserves the pre-persistence contract that a plugin owning /chat replaces the built-in chat UI entirely. * Wait for usePlugins() to finish loading before mounting the persistent host. Manifests arrive asynchronously from /api/dashboard/plugins, so without the `!pluginsLoading` gate the host would mount with manifests=[], spawn a PTY, and then unmount mid-session when the manifest list resolves and reveals a /chat override. Typical delay is <50ms; worst case is the 2s plugin- registration safety timeout. Cheaper than killing someone's conversation underneath them. * Gate page-header slot (`setEnd`), the mobile sheet's portalled render, and body-scroll lock on a new `isActive` prop so the hidden ChatPage doesn't fight the active page for shared state. The scroll-lock effect keys on the *derived* `mobilePanelOpen` (which is `isActive && mobilePanelOpenRaw`) rather than the raw state — that way tab-switch flips the dep false, fires the cleanup, and releases `document.body.style.overflow`. Keying on the raw state would leave body.overflow="hidden" stuck on /sessions and every other tab until the user navigated back to /chat and explicitly closed the sheet. * When isActive flips false to true, force a double-rAF fit: display:none collapses the host box and ResizeObserver does not fire on display changes, so xterm would otherwise stay at a stale or 1x1 grid. Also early-return from syncTerminalMetrics when the host has zero area, since fit() on a zero-sized element produces a 1x1 terminal. * Focus handling on tab return: only steal focus into the terminal if focus wasn't already parked somewhere inside ChatPage (e.g. the sidebar model picker, a tool-call entry). Yanking focus away from whatever the user last clicked is surprising and a screen-reader foot-gun; the typical "first activation" case still focuses the terminal because document.activeElement is <body> at that point. Trade-off worth flagging, deliberately not mitigated in this change: while hidden, ChatPage still holds a PTY child + WebSocket + xterm instance for the dashboard's full lifetime. The WS keeps delivering bytes and xterm keeps parsing them into a display:none host (cheap — no paint work, but not free). Reasonable costs to pay for the session preservation; if they become a problem we can pause `term.write` when !isActive or idle-disconnect after N minutes hidden. Lint clean on touched files. tsc -b && vite build pass.
731 lines
23 KiB
TypeScript
731 lines
23 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,
|
|
Database,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Globe,
|
|
Heart,
|
|
KeyRound,
|
|
Loader2,
|
|
Menu,
|
|
MessageSquare,
|
|
Package,
|
|
Puzzle,
|
|
RotateCw,
|
|
Settings,
|
|
Shield,
|
|
Sparkles,
|
|
Star,
|
|
Terminal,
|
|
Wrench,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { SelectionSwitcher, 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 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 ChatPageHost below — 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,
|
|
"/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: "/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,
|
|
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
|
|
type="button"
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label={t.app.openNavigation}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="app-sidebar"
|
|
className={cn(
|
|
"inline-flex h-8 w-8 items-center justify-center",
|
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
)}
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
</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
|
|
type="button"
|
|
aria-label={t.app.closeNavigation}
|
|
onClick={closeMobile}
|
|
className={cn(
|
|
"lg:hidden fixed inset-0 z-40",
|
|
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<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 px-5",
|
|
"border-b border-current/20",
|
|
)}
|
|
>
|
|
<Typography
|
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
>
|
|
Hermes
|
|
<br />
|
|
Agent
|
|
</Typography>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={closeMobile}
|
|
aria-label={t.app.closeNavigation}
|
|
className={cn(
|
|
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
|
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<PluginSlot name="header-left" />
|
|
|
|
<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>
|
|
|
|
{/*
|
|
Persistent chat host: always mounted when `hermes dashboard
|
|
--tui` is active, visibility toggled by route. Keeping the
|
|
tree alive preserves the xterm instance, its WebSocket, and
|
|
the PTY child that backs the TUI session — so navigating to
|
|
another tab and returning lands the user in the same
|
|
conversation instead of spawning a fresh session.
|
|
|
|
The host sits alongside <Routes> (not inside one) because
|
|
React Router unmounts route elements on path change, which
|
|
is exactly the destructive lifecycle we're avoiding.
|
|
|
|
Trade-off worth knowing about: while hidden, ChatPage still
|
|
holds a PTY child + WebSocket + xterm instance for the
|
|
dashboard's full lifetime. The WS keeps delivering bytes
|
|
and xterm keeps parsing them into a display:none host
|
|
(cheap — no paint work, but not free). If this becomes a
|
|
resource problem we can pause `term.write` when !isActive
|
|
or idle-disconnect after N minutes hidden; neither is
|
|
shipped today.
|
|
*/}
|
|
{embeddedChat && !pluginsLoading && !chatOverriddenByPlugin && (
|
|
<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}>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleClick(action)}
|
|
disabled={disabled}
|
|
aria-busy={busy}
|
|
className={cn(
|
|
"group relative flex w-full items-center gap-3",
|
|
"px-5 py-1.5",
|
|
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
|
"text-left whitespace-nowrap transition-opacity cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
busy
|
|
? "text-midground opacity-100"
|
|
: "opacity-60 hover:opacity-100",
|
|
"disabled:cursor-not-allowed disabled:opacity-30",
|
|
)}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
) : (
|
|
<Icon
|
|
className={cn(
|
|
"h-3.5 w-3.5 shrink-0",
|
|
isActionRunning && spin && "animate-spin",
|
|
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" }}
|
|
/>
|
|
)}
|
|
</button>
|
|
</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;
|
|
}
|