mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 07:21:37 +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,
|
||||
};
|
||||
|
||||
/** 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> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
@@ -91,6 +98,14 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/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",
|
||||
@@ -240,7 +255,7 @@ function buildRoutes(
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
const { manifests } = usePlugins();
|
||||
const { manifests, loading: pluginsLoading } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
@@ -249,10 +264,32 @@ export default function App() {
|
||||
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": ChatPage } : {}),
|
||||
...(embeddedChat ? { "/chat": ChatRouteSink } : {}),
|
||||
}),
|
||||
[embeddedChat],
|
||||
);
|
||||
@@ -519,6 +556,40 @@ export default function App() {
|
||||
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>
|
||||
|
||||
@@ -101,11 +101,15 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number {
|
||||
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 termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | 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();
|
||||
// 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).
|
||||
@@ -116,7 +120,16 @@ export default function ChatPage() {
|
||||
);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
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 { t } = useI18n();
|
||||
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
||||
@@ -168,6 +181,12 @@ export default function ChatPage() {
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
setEnd(null);
|
||||
return;
|
||||
@@ -191,7 +210,7 @@ export default function ChatPage() {
|
||||
</button>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||
|
||||
const handleCopyLast = () => {
|
||||
const ws = wsRef.current;
|
||||
@@ -392,6 +411,12 @@ export default function ChatPage() {
|
||||
|
||||
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
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 nextSize = terminalFontSizeForWidth(w);
|
||||
const nextLh = terminalLineHeightForWidth(w);
|
||||
@@ -422,6 +447,7 @@ export default function ChatPage() {
|
||||
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||
}
|
||||
};
|
||||
syncMetricsRef.current = syncTerminalMetrics;
|
||||
|
||||
const scheduleSyncTerminalMetrics = () => {
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
@@ -565,6 +591,7 @@ export default function ChatPage() {
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
syncMetricsRef.current = null;
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
@@ -593,6 +620,51 @@ export default function ChatPage() {
|
||||
};
|
||||
}, [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:
|
||||
// outer flex column — sits inside the dashboard's content area
|
||||
// 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`
|
||||
// descendants below those layers (see Toast.tsx).
|
||||
const mobileModelToolsPortal =
|
||||
isActive &&
|
||||
narrow &&
|
||||
portalRoot &&
|
||||
createPortal(
|
||||
|
||||
Reference in New Issue
Block a user