mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
fix(dashboard): persist chat tab state across tab switches
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.
This commit is contained in:
parent
185ecc71f1
commit
d293e0051e
2 changed files with 150 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue