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.