diff --git a/web/src/App.tsx b/web/src/App.tsx index f4285a21b4..ab6146dd0c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 + * ) 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 = { "/": RootRedirect, "/sessions": SessionsPage, @@ -91,6 +98,14 @@ const BUILTIN_ROUTES_CORE: Record = { "/docs": DocsPage, }; +// Route placeholder for /chat. The persistent ChatPage host (rendered +// outside 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 — 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={} /> + + {/* + 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 (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 && ( +
+ +
+ )} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 525739b192..900893be7d 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(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 | 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() { , ); 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 | 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 ; 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 ). 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(