mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
1 Commits
feat/langf
...
fix/chat-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd2542ba5 |
@@ -78,7 +78,14 @@ const CHAT_NAV_ITEM: NavItem = {
|
|||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Built-in routes except /chat (only with `hermes dashboard --tui`). */
|
/**
|
||||||
|
* 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> = {
|
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||||
"/": RootRedirect,
|
"/": RootRedirect,
|
||||||
"/sessions": SessionsPage,
|
"/sessions": SessionsPage,
|
||||||
@@ -91,6 +98,14 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||||||
"/docs": DocsPage,
|
"/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[] = [
|
const BUILTIN_NAV_REST: NavItem[] = [
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
@@ -240,7 +255,7 @@ function buildRoutes(
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { manifests } = usePlugins();
|
const { manifests, loading: pluginsLoading } = usePlugins();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||||
@@ -249,10 +264,32 @@ export default function App() {
|
|||||||
const isChatRoute = normalizedPath === "/chat";
|
const isChatRoute = normalizedPath === "/chat";
|
||||||
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
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(
|
const builtinRoutes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...BUILTIN_ROUTES_CORE,
|
...BUILTIN_ROUTES_CORE,
|
||||||
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
...(embeddedChat ? { "/chat": ChatRouteSink } : {}),
|
||||||
}),
|
}),
|
||||||
[embeddedChat],
|
[embeddedChat],
|
||||||
);
|
);
|
||||||
@@ -519,6 +556,40 @@ export default function App() {
|
|||||||
element={<Navigate to="/sessions" replace />}
|
element={<Navigate to="/sessions" replace />}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</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>
|
</div>
|
||||||
<PluginSlot name="post-main" />
|
<PluginSlot name="post-main" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,11 +101,15 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number {
|
|||||||
return layoutWidthPx < 1024 ? 1.02 : 1.15;
|
return layoutWidthPx < 1024 ? 1.02 : 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
// Exposed to the main metrics-sync effect so it can refit the terminal
|
||||||
|
// the moment `isActive` flips back to true (display:none → display:flex
|
||||||
|
// collapses the host's box, so ResizeObserver never fires on return).
|
||||||
|
const syncMetricsRef = useRef<(() => void) | null>(null);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
// Lazy-init: the missing-token check happens at construction so the effect
|
// Lazy-init: the missing-token check happens at construction so the effect
|
||||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||||
@@ -116,7 +120,16 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
|
// Raw state for the mobile side-sheet + a derived value that force-
|
||||||
|
// closes whenever the chat tab isn't active. The *derived* value is
|
||||||
|
// what side-effects (body-scroll lock, keydown listener, portal render)
|
||||||
|
// key on — that way switching to another tab triggers the effect's
|
||||||
|
// cleanup, releasing the scroll-lock on /sessions etc. Returning to
|
||||||
|
// /chat re-runs the effect (derived flips back to true) and re-locks.
|
||||||
|
// Keying on the raw state would leak the body.overflow="hidden" across
|
||||||
|
// tabs because the dep wouldn't change on tab switch.
|
||||||
|
const [mobilePanelOpenRaw, setMobilePanelOpen] = useState(false);
|
||||||
|
const mobilePanelOpen = isActive && mobilePanelOpenRaw;
|
||||||
const { setEnd } = usePageHeader();
|
const { setEnd } = usePageHeader();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
||||||
@@ -168,6 +181,12 @@ export default function ChatPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// When hidden (non-chat tab) we must not register the header button —
|
||||||
|
// another page owns the header's end slot at that point.
|
||||||
|
if (!isActive) {
|
||||||
|
setEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!narrow) {
|
if (!narrow) {
|
||||||
setEnd(null);
|
setEnd(null);
|
||||||
return;
|
return;
|
||||||
@@ -191,7 +210,7 @@ export default function ChatPage() {
|
|||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
return () => setEnd(null);
|
return () => setEnd(null);
|
||||||
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||||
|
|
||||||
const handleCopyLast = () => {
|
const handleCopyLast = () => {
|
||||||
const ws = wsRef.current;
|
const ws = wsRef.current;
|
||||||
@@ -392,6 +411,12 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
const syncTerminalMetrics = () => {
|
const syncTerminalMetrics = () => {
|
||||||
|
// display:none hosts have clientWidth/Height = 0, which fit() turns
|
||||||
|
// into a 1x1 terminal. Skip entirely while hidden; the visibility
|
||||||
|
// effect below runs another fit as soon as the tab is shown again.
|
||||||
|
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const w = terminalTierWidthPx(host);
|
const w = terminalTierWidthPx(host);
|
||||||
const nextSize = terminalFontSizeForWidth(w);
|
const nextSize = terminalFontSizeForWidth(w);
|
||||||
const nextLh = terminalLineHeightForWidth(w);
|
const nextLh = terminalLineHeightForWidth(w);
|
||||||
@@ -422,6 +447,7 @@ export default function ChatPage() {
|
|||||||
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
syncMetricsRef.current = syncTerminalMetrics;
|
||||||
|
|
||||||
const scheduleSyncTerminalMetrics = () => {
|
const scheduleSyncTerminalMetrics = () => {
|
||||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||||
@@ -565,6 +591,7 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unmounting = true;
|
unmounting = true;
|
||||||
|
syncMetricsRef.current = null;
|
||||||
onDataDisposable.dispose();
|
onDataDisposable.dispose();
|
||||||
onResizeDisposable.dispose();
|
onResizeDisposable.dispose();
|
||||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||||
@@ -593,6 +620,51 @@ export default function ChatPage() {
|
|||||||
};
|
};
|
||||||
}, [channel]);
|
}, [channel]);
|
||||||
|
|
||||||
|
// When the user returns to the chat tab (isActive: false → true), the
|
||||||
|
// terminal host just transitioned from display:none to display:flex.
|
||||||
|
// ResizeObserver won't fire on that kind of style-driven box change —
|
||||||
|
// xterm thinks its grid is still whatever it was when the tab was
|
||||||
|
// hidden (or 0×0, if it was hidden before first fit). Force a refit
|
||||||
|
// after two animation frames so layout has committed.
|
||||||
|
//
|
||||||
|
// Focus handling: we only steal focus back into the terminal when
|
||||||
|
// nothing else inside ChatPage was holding it (typically the first
|
||||||
|
// activation after mount, where document.activeElement is <body>; or
|
||||||
|
// a return after the user had been typing in the terminal, where
|
||||||
|
// focus was already on the xterm textarea before the tab got hidden
|
||||||
|
// and has since fallen back to <body>). If the user had clicked
|
||||||
|
// into the sidebar (model picker, tool-call entry) before switching
|
||||||
|
// tabs, we must not yank focus away from wherever they left it when
|
||||||
|
// they come back — that's a surprise and an a11y foot-gun.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
let raf1 = 0;
|
||||||
|
let raf2 = 0;
|
||||||
|
raf1 = requestAnimationFrame(() => {
|
||||||
|
raf1 = 0;
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
raf2 = 0;
|
||||||
|
syncMetricsRef.current?.();
|
||||||
|
const host = hostRef.current;
|
||||||
|
const active = typeof document !== "undefined"
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
const focusIsElsewhereInChatPage =
|
||||||
|
active !== null &&
|
||||||
|
active !== document.body &&
|
||||||
|
host !== null &&
|
||||||
|
!host.contains(active);
|
||||||
|
if (!focusIsElsewhereInChatPage) {
|
||||||
|
termRef.current?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (raf1) cancelAnimationFrame(raf1);
|
||||||
|
if (raf2) cancelAnimationFrame(raf2);
|
||||||
|
};
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
// Layout:
|
// Layout:
|
||||||
// outer flex column — sits inside the dashboard's content area
|
// outer flex column — sits inside the dashboard's content area
|
||||||
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
|
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
|
||||||
@@ -612,6 +684,7 @@ export default function ChatPage() {
|
|||||||
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
||||||
// descendants below those layers (see Toast.tsx).
|
// descendants below those layers (see Toast.tsx).
|
||||||
const mobileModelToolsPortal =
|
const mobileModelToolsPortal =
|
||||||
|
isActive &&
|
||||||
narrow &&
|
narrow &&
|
||||||
portalRoot &&
|
portalRoot &&
|
||||||
createPortal(
|
createPortal(
|
||||||
|
|||||||
Reference in New Issue
Block a user