fix: mobile chat in new layout

This commit is contained in:
Austin Pickett 2026-04-24 12:07:46 -04:00
parent f49afd3122
commit 63975aa75b
17 changed files with 526 additions and 100 deletions

View file

@ -6715,9 +6715,15 @@ def cmd_dashboard(args):
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
except ImportError:
print("Web UI dependencies not installed.")
print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'")
except ImportError as e:
print("Web UI dependencies not installed (need fastapi + uvicorn).")
print(
f"Re-install the package into this interpreter so metadata updates apply:\n"
f" cd {PROJECT_ROOT}\n"
f" {sys.executable} -m pip install -e .\n"
"If `pip` is missing in this venv, use: uv pip install -e ."
)
print(f"Import error: {e}")
sys.exit(1)
if "HERMES_WEB_DIST" not in os.environ:
@ -6726,11 +6732,13 @@ def cmd_dashboard(args):
from hermes_cli.web_server import start_server
embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
start_server(
host=args.host,
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
embedded_chat=embedded_chat,
)
@ -8916,6 +8924,14 @@ Examples:
action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.add_argument(
"--tui",
action="store_true",
help=(
"Expose the in-browser Chat tab (embedded `hermes --tui` via PTY/WebSocket). "
"Alternatively set HERMES_DASHBOARD_TUI=1."
),
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================

View file

@ -96,10 +96,18 @@ class PtyBridge:
ordinary exec failures (missing binary, bad cwd, etc.).
"""
if not _PTY_AVAILABLE:
raise PtyUnavailableError(
"Pseudo-terminals are unavailable on this platform. "
"Hermes Agent supports Windows only via WSL."
)
if sys.platform.startswith("win"):
raise PtyUnavailableError(
"Pseudo-terminals are unavailable on this platform. "
"Hermes Agent supports Windows only via WSL."
)
if ptyprocess is None:
raise PtyUnavailableError(
"The `ptyprocess` package is missing. "
"Install with: pip install ptyprocess "
"(or pip install -e '.[pty]')."
)
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
# Let caller-supplied env fully override inheritance; if they pass
# None we inherit the server's env (same semantics as subprocess).
spawn_env = os.environ.copy() if env is None else env

View file

@ -73,6 +73,10 @@ app = FastAPI(title="Hermes Agent", version=__version__)
_SESSION_TOKEN = secrets.token_urlsafe(32)
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
# In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui``
# or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`.
_DASHBOARD_EMBEDDED_CHAT_ENABLED = False
# Simple rate limiter for the reveal endpoint
_reveal_timestamps: List[float] = []
_REVEAL_MAX_PER_WINDOW = 5
@ -2370,6 +2374,10 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
@app.websocket("/api/pty")
async def pty_ws(ws: WebSocket) -> None:
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return
# --- auth + loopback check (before accept so we can close cleanly) ---
token = ws.query_params.get("token", "")
expected = _SESSION_TOKEN
@ -2476,6 +2484,10 @@ async def pty_ws(ws: WebSocket) -> None:
@app.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None:
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401)
@ -2505,6 +2517,10 @@ async def gateway_ws(ws: WebSocket) -> None:
@app.websocket("/api/pub")
async def pub_ws(ws: WebSocket) -> None:
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401)
@ -2531,6 +2547,10 @@ async def pub_ws(ws: WebSocket) -> None:
@app.websocket("/api/events")
async def events_ws(ws: WebSocket) -> None:
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return
token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401)
@ -2591,8 +2611,10 @@ def mount_spa(application: FastAPI):
def _serve_index():
"""Return index.html with the session token injected."""
html = _index_path.read_text()
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
token_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";</script>'
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
)
html = html.replace("</head>", f"{token_script}</head>", 1)
return HTMLResponse(
@ -3105,10 +3127,15 @@ def start_server(
port: int = 9119,
open_browser: bool = True,
allow_public: bool = False,
*,
embedded_chat: bool = False,
):
"""Start the web UI server."""
import uvicorn
global _DASHBOARD_EMBEDDED_CHAT_ENABLED
_DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
if host not in _LOCALHOST and not allow_public:
raise SystemExit(

View file

@ -34,6 +34,11 @@ dependencies = [
"edge-tts>=7.2.7,<8",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
# Web dashboard (`hermes dashboard` — localhost SPA + API)
"fastapi>=0.104.0,<1",
"uvicorn[standard]>=0.24.0,<1",
# Embedded Chat tab (/api/pty) on POSIX — required for PTY, not optional
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
]
[project.optional-dependencies]
@ -78,6 +83,7 @@ termux = [
]
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
# Same pins as core; kept so `hermes-agent[web]` stays a no-op alias for older docs/scripts.
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",

View file

@ -1707,6 +1707,7 @@ class TestPtyWebSocket:
# Avoid exec'ing the actual TUI in tests: every test below installs
# its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
self.token = ws._SESSION_TOKEN
self.client = TestClient(ws.app)
@ -1719,6 +1720,15 @@ class TestPtyWebSocket:
q = {"token": tok, **params}
return f"/api/pty?{urlencode(q)}"
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect(self._url()):
pass
assert exc.value.code == 4403
def test_rejects_missing_token(self, monkeypatch):
monkeypatch.setattr(
self.ws_module,

10
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-16T11:49:00.318115Z"
exclude-newer = "2026-04-17T15:09:44.835508886Z"
exclude-newer-span = "P7D"
[[package]]
@ -1870,13 +1870,14 @@ wheels = [
[[package]]
name = "hermes-agent"
version = "0.10.0"
version = "0.11.0"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "edge-tts" },
{ name = "exa-py" },
{ name = "fal-client" },
{ name = "fastapi" },
{ name = "fire" },
{ name = "firecrawl-py" },
{ name = "httpx", extra = ["socks"] },
@ -1884,6 +1885,7 @@ dependencies = [
{ name = "openai" },
{ name = "parallel-web" },
{ name = "prompt-toolkit" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
@ -1891,6 +1893,7 @@ dependencies = [
{ name = "requests" },
{ name = "rich" },
{ name = "tenacity" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
@ -2059,6 +2062,7 @@ requires-dist = [
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
{ name = "exa-py", specifier = ">=2.9.0,<3" },
{ name = "fal-client", specifier = ">=0.13.1,<1" },
{ name = "fastapi", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
@ -2105,6 +2109,7 @@ requires-dist = [
{ name = "openai", specifier = ">=2.21.0,<3" },
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
{ name = "pydantic", specifier = ">=2.12.5,<3" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
@ -2131,6 +2136,7 @@ requires-dist = [
{ name = "tenacity", specifier = ">=9.1.4,<10" },
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },

View file

@ -65,15 +65,22 @@ import { useI18n } from "@/i18n";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */
const BUILTIN_ROUTES: Record<string, ComponentType> = {
const CHAT_NAV_ITEM: NavItem = {
path: "/chat",
labelKey: "chat",
label: "Chat",
icon: Terminal,
};
/** Built-in routes except /chat (only with `hermes dashboard --tui`). */
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/": RootRedirect,
"/chat": ChatPage,
"/sessions": SessionsPage,
"/analytics": AnalyticsPage,
"/logs": LogsPage,
@ -84,8 +91,7 @@ const BUILTIN_ROUTES: Record<string, ComponentType> = {
"/docs": DocsPage,
};
const BUILTIN_NAV: NavItem[] = [
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
const BUILTIN_NAV_REST: NavItem[] = [
{
path: "/sessions",
labelKey: "sessions",
@ -170,7 +176,10 @@ function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem
return items;
}
function buildRoutes(manifests: PluginManifest[]): Array<{
function buildRoutes(
builtinRoutes: Record<string, ComponentType>,
manifests: PluginManifest[],
): Array<{
key: string;
path: string;
element: ReactNode;
@ -192,7 +201,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
element: ReactNode;
}> = [];
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
for (const [path, Component] of Object.entries(builtinRoutes)) {
const om = byOverride.get(path);
if (om) {
routes.push({
@ -207,7 +216,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
for (const m of addons) {
if (m.tab.hidden) continue;
if (BUILTIN_ROUTES[m.tab.path]) continue;
if (builtinRoutes[m.tab.path]) continue;
routes.push({
key: `plugin:${m.name}`,
path: m.tab.path,
@ -217,7 +226,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
for (const m of manifests) {
if (!m.tab.hidden) continue;
if (BUILTIN_ROUTES[m.tab.path] || m.tab.override) continue;
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
routes.push({
key: `plugin:hidden:${m.name}`,
path: m.tab.path,
@ -236,12 +245,32 @@ export default function App() {
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
const embeddedChat = isDashboardEmbeddedChatEnabled();
const builtinRoutes = useMemo(
() => ({
...BUILTIN_ROUTES_CORE,
...(embeddedChat ? { "/chat": ChatPage } : {}),
}),
[embeddedChat],
);
const builtinNav = useMemo(
() =>
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
[embeddedChat],
);
const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, manifests),
[manifests],
() => buildNavItems(builtinNav, manifests),
[builtinNav, manifests],
);
const routes = useMemo(
() => buildRoutes(builtinRoutes, manifests),
[builtinRoutes, manifests],
);
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
const pluginTabMeta = useMemo(
() =>
manifests
@ -468,8 +497,9 @@ export default function App() {
className={cn(
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
"px-3 sm:px-6",
"pt-2 sm:pt-4 lg:pt-6",
"pb-4 sm:pb-8",
isChatRoute
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
isDocsRoute && "min-h-0 flex-1",
)}
>
@ -477,7 +507,7 @@ export default function App() {
<div
className={cn(
"w-full min-w-0",
isDocsRoute && "min-h-0 flex flex-1 flex-col",
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
)}
>
<Routes>

View file

@ -31,6 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
@ -66,9 +67,10 @@ const STATE_TONE: Record<ConnectionState, string> = {
interface ChatSidebarProps {
channel: string;
className?: string;
}
export function ChatSidebar({ channel }: ChatSidebarProps) {
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
@ -284,7 +286,12 @@ export function ChatSidebar({ channel }: ChatSidebarProps) {
const banner = error ?? info.credential_warning ?? null;
return (
<aside className="flex h-full w-80 shrink-0 flex-col gap-3 normal-case">
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
className,
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground">

View file

@ -34,6 +34,8 @@ export function PageHeaderProvider({
);
const displayTitle = titleOverride ?? defaultTitle;
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
const value = useMemo(
() => ({
setAfterTitle,
@ -59,8 +61,10 @@ export function PageHeaderProvider({
>
<div
className={cn(
"flex h-full w-full min-w-0 flex-1 flex-col justify-center gap-2",
"px-3 py-2 sm:flex-row sm:items-center sm:gap-3 sm:px-6 sm:py-0",
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
isChatRoute
? "flex-row items-center"
: "flex-col justify-center sm:flex-row sm:items-center",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
@ -74,7 +78,12 @@ export function PageHeaderProvider({
</div>
{end ? (
<div className="flex w-full min-w-0 justify-end sm:max-w-md sm:flex-1">
<div
className={cn(
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
isChatRoute ? "w-auto shrink-0" : "w-full",
)}
>
{end}
</div>
) : null}
@ -84,7 +93,9 @@ export function PageHeaderProvider({
<main
className={cn(
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
"overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
isChatRoute
? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
)}
>
{children}

View file

@ -53,6 +53,7 @@ export const en: Translations = {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Close navigation",
closeModelTools: "Close model and tools",
footer: {
org: "Nous Research",
},
@ -76,6 +77,8 @@ export const en: Translations = {
sessions: "Sessions",
skills: "Skills",
},
modelToolsSheetSubtitle: "& tools",
modelToolsSheetTitle: "Model",
navigation: "Navigation",
openDocumentation: "Open documentation in a new tab",
openNavigation: "Open navigation",

View file

@ -53,6 +53,7 @@ export interface Translations {
brand: string;
brandShort: string;
closeNavigation: string;
closeModelTools: string;
footer: {
org: string;
};
@ -76,6 +77,8 @@ export interface Translations {
sessions: string;
skills: string;
};
modelToolsSheetSubtitle: string;
modelToolsSheetTitle: string;
navigation: string;
openDocumentation: string;
openNavigation: string;

View file

@ -52,6 +52,7 @@ export const zh: Translations = {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "关闭导航",
closeModelTools: "关闭模型与工具",
footer: {
org: "Nous Research",
},
@ -75,6 +76,8 @@ export const zh: Translations = {
sessions: "会话",
skills: "技能",
},
modelToolsSheetSubtitle: "与工具",
modelToolsSheetTitle: "模型",
navigation: "导航",
openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航",

View file

@ -0,0 +1,15 @@
declare global {
interface Window {
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
/** @deprecated Older injected name; treated as on when true. */
__HERMES_DASHBOARD_TUI__?: boolean;
}
}
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
export function isDashboardEmbeddedChatEnabled(): boolean {
if (typeof window === "undefined") return false;
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
return window.__HERMES_DASHBOARD_TUI__ === true;
}

View file

@ -22,11 +22,16 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Copy } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Typography } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { ChatSidebar } from "@/components/ChatSidebar";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
function buildWsUrl(
token: string,
@ -62,6 +67,39 @@ const TERMINAL_THEME = {
selectionBackground: "#f0e6d244",
};
/**
* CSS width for xterm font tiers.
*
* Prefer the terminal host's `clientWidth` Chrome DevTools device mode often
* keeps `window.innerWidth` at the full desktop value while the *drawn* layout
* is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
*/
function terminalTierWidthPx(host: HTMLElement | null): number {
if (typeof window === "undefined") return 1280;
const fromHost = host?.clientWidth ?? 0;
if (fromHost > 2) return Math.round(fromHost);
const doc = document.documentElement?.clientWidth ?? 0;
const vv = window.visualViewport;
const inner = window.innerWidth;
const vvw = vv?.width ?? inner;
const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
return Math.max(1, Math.round(layout));
}
function terminalFontSizeForWidth(layoutWidthPx: number): number {
if (layoutWidthPx < 300) return 7;
if (layoutWidthPx < 360) return 8;
if (layoutWidthPx < 420) return 9;
if (layoutWidthPx < 520) return 10;
if (layoutWidthPx < 720) return 11;
if (layoutWidthPx < 1024) return 12;
return 14;
}
function terminalLineHeightForWidth(layoutWidthPx: number): number {
return layoutWidthPx < 1024 ? 1.02 : 1.15;
}
export default function ChatPage() {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
@ -77,10 +115,83 @@ export default function ChatPage() {
);
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
const { setEnd } = usePageHeader();
const { t } = useI18n();
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
const modelToolsLabel = useMemo(
() => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
[t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
const [narrow, setNarrow] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(max-width: 1023px)").matches
: false,
);
const resumeRef = useRef<string | null>(searchParams.get("resume"));
const channel = useMemo(() => generateChannelId(), []);
useEffect(() => {
const mql = window.matchMedia("(max-width: 1023px)");
const sync = () => setNarrow(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!mobilePanelOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMobilePanel();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [mobilePanelOpen, closeMobilePanel]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setMobilePanelOpen(false);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
useEffect(() => {
if (!narrow) {
setEnd(null);
return;
}
setEnd(
<button
type="button"
onClick={() => setMobilePanelOpen(true)}
className={cn(
"inline-flex items-center gap-1.5 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
"text-midground/80 hover:text-midground hover:bg-midground/5",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
"shrink-0 cursor-pointer",
)}
aria-expanded={mobilePanelOpen}
aria-controls="chat-side-panel"
>
<PanelRight className="h-3 w-3 shrink-0" />
{modelToolsLabel}
</button>,
);
return () => setEnd(null);
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
const handleCopyLast = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
@ -110,13 +221,17 @@ export default function ChatPage() {
return;
}
const tierW0 = terminalTierWidthPx(host);
const term = new Terminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontSize: 14,
lineHeight: 1.2,
fontSize: terminalFontSizeForWidth(tierW0),
lineHeight: terminalLineHeightForWidth(tierW0),
letterSpacing: 0,
fontWeight: "400",
fontWeightBold: "700",
macOptionIsMeta: true,
scrollback: 0,
theme: TERMINAL_THEME,
@ -212,19 +327,23 @@ export default function ChatPage() {
term.open(host);
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints
// each cell at an integer-pixel position. Box-drawing glyphs connect
// cleanly between rows (no DOM baseline / line-height math). Falls
// back to the default DOM renderer if WebGL is unavailable.
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch (err) {
console.warn(
"[hermes-chat] WebGL renderer unavailable; falling back to default",
err,
);
// WebGL draws from a texture atlas sized with device pixels. On phones and
// in DevTools device mode that often produces *visually* much larger cells
// than `fontSize` suggests — users see "huge" text even at 79px settings.
// The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
// hosts. Wide layouts still get WebGL for crisp box-drawing.
const useWebgl = terminalTierWidthPx(host) >= 768;
if (useWebgl) {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch (err) {
console.warn(
"[hermes-chat] WebGL renderer unavailable; falling back to default",
err,
);
}
}
// Initial fit + resize observer. fit.fit() reads the container's
@ -244,22 +363,65 @@ export default function ChatPage() {
// frames. rAF→rAF guarantees one layout commit between the two
// callbacks, giving CSS transitions and font metrics time to finalize
// before we take the authoritative measurement.
let rafId = 0;
const scheduleFit = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
try {
fit.fit();
} catch {
// Element was removed mid-resize; cleanup will handle it.
}
let hostSyncRaf = 0;
const scheduleHostSync = () => {
if (hostSyncRaf) return;
hostSyncRaf = requestAnimationFrame(() => {
hostSyncRaf = 0;
syncTerminalMetrics();
});
};
fit.fit();
const ro = new ResizeObserver(scheduleFit);
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
const syncTerminalMetrics = () => {
const w = terminalTierWidthPx(host);
const nextSize = terminalFontSizeForWidth(w);
const nextLh = terminalLineHeightForWidth(w);
const fontChanged =
term.options.fontSize !== nextSize ||
term.options.lineHeight !== nextLh;
if (fontChanged) {
term.options.fontSize = nextSize;
term.options.lineHeight = nextLh;
}
try {
fit.fit();
} catch {
return;
}
if (fontChanged && term.rows > 0) {
try {
term.refresh(0, term.rows - 1);
} catch {
/* ignore */
}
}
if (
fontChanged &&
wsRef.current &&
wsRef.current.readyState === WebSocket.OPEN
) {
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
}
};
const scheduleSyncTerminalMetrics = () => {
if (metricsDebounce) clearTimeout(metricsDebounce);
metricsDebounce = setTimeout(() => {
metricsDebounce = null;
syncTerminalMetrics();
}, 60);
};
const ro = new ResizeObserver(() => scheduleHostSync());
ro.observe(host);
window.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics);
scheduleHostSync();
requestAnimationFrame(() => scheduleHostSync());
// Double-rAF authoritative fit. On the second frame the layout has
// committed at least once since mount; fit.fit() then reads the
// stable container size. We always send a RESIZE escape afterwards
@ -272,15 +434,7 @@ export default function ChatPage() {
settleRaf1 = 0;
settleRaf2 = requestAnimationFrame(() => {
settleRaf2 = 0;
try {
fit.fit();
} catch {
return;
}
const sock = wsRef.current;
if (sock && sock.readyState === WebSocket.OPEN) {
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
}
syncTerminalMetrics();
});
});
@ -387,8 +541,18 @@ export default function ChatPage() {
return () => {
onDataDisposable.dispose();
onResizeDisposable.dispose();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
"resize",
scheduleSyncTerminalMetrics,
);
window.visualViewport?.removeEventListener(
"scroll",
scheduleSyncTerminalMetrics,
);
ro.disconnect();
if (rafId) cancelAnimationFrame(rafId);
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1);
if (settleRaf2) cancelAnimationFrame(settleRaf2);
ws.close();
@ -416,52 +580,149 @@ export default function ChatPage() {
//
// `normal-case` opts out of the dashboard's global `uppercase` rule on
// the root `<div>` in App.tsx — terminal output must preserve case.
//
// Mobile model/tools sheet is portaled to `document.body` so it stacks
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed`
// descendants below those layers (see Toast.tsx).
const mobileModelToolsPortal =
narrow &&
portalRoot &&
createPortal(
<>
{mobilePanelOpen && (
<button
type="button"
aria-label={t.app.closeModelTools}
onClick={closeMobilePanel}
className={cn(
"fixed inset-0 z-[55]",
"bg-black/60 backdrop-blur-sm cursor-pointer",
)}
/>
)}
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className={cn(
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
"border-l border-current/20 text-midground",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
"[background:var(--component-sidebar-background)]",
"[clip-path:var(--component-sidebar-clip-path)]",
"[border-image:var(--component-sidebar-border-image)]",
mobilePanelOpen
? "translate-x-0"
: "pointer-events-none translate-x-full",
)}
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
)}
>
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.modelToolsSheetTitle}
<br />
{t.app.modelToolsSheetSubtitle}
</Typography>
<button
type="button"
onClick={closeMobilePanel}
aria-label={t.app.closeModelTools}
className={cn(
"inline-flex h-7 w-7 items-center justify-center",
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
)}
>
<X className="h-4 w-4" />
</button>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
"border-t border-current/10",
)}
>
<ChatSidebar channel={channel} />
</div>
</div>
</>,
portalRoot,
);
return (
<div className="flex h-[calc(100vh-10rem)] flex-col gap-2 normal-case">
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
{mobileModelToolsPortal}
{banner && (
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
{banner}
</div>
)}
<div className="flex min-h-0 flex-1 gap-3">
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
<div
className="relative min-w-0 flex-1 overflow-hidden rounded-lg"
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
"p-2 sm:p-3",
)}
style={{
backgroundColor: TERMINAL_THEME.background,
padding: "12px",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<div ref={hostRef} className="h-full w-full" />
<div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
<button
type="button"
onClick={handleCopyLast}
title="Copy last assistant response as raw markdown"
aria-label="Copy last assistant response"
className={[
"absolute bottom-4 right-4 z-10",
"flex items-center gap-1.5",
className={cn(
"absolute z-10 flex items-center gap-1.5",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"px-2.5 py-1.5 text-xs",
"opacity-60 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150",
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
"cursor-pointer",
].join(" ")}
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME.foreground }}
>
<Copy className="h-3 w-3" />
<span className="tracking-wide">
<Copy className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"}
</span>
</button>
</div>
<div className="hidden min-h-0 lg:block">
<ChatSidebar channel={channel} />
</div>
{!narrow && (
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<ChatSidebar channel={channel} />
</div>
</div>
)}
</div>
</div>
);

View file

@ -35,7 +35,7 @@ export default function DocsPage() {
<div
className={cn(
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
"-mx-3 sm:-mx-6",
"pt-1 sm:pt-2",
)}
>
<iframe

View file

@ -46,6 +46,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@/hooks/useToast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{
@ -258,6 +259,7 @@ function SessionRow({
isExpanded,
onToggle,
onDelete,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
@ -265,6 +267,7 @@ function SessionRow({
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
@ -350,19 +353,21 @@ function SessionRow({
<Badge variant="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play className="h-3.5 w-3.5" />
</Button>
{resumeInChatEnabled && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@ -422,6 +427,7 @@ export default function SessionsPage() {
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
useLayoutEffect(() => {
if (loading) {
@ -786,6 +792,7 @@ export default function SessionsPage() {
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>

View file

@ -17,6 +17,10 @@ const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
*/
function hermesDevToken(): Plugin {
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
const EMBEDDED_RE =
/window\.__HERMES_DASHBOARD_EMBEDDED_CHAT__\s*=\s*(true|false)/;
const LEGACY_TUI_RE =
/window\.__HERMES_DASHBOARD_TUI__\s*=\s*(true|false)/;
return {
name: "hermes:dev-session-token",
@ -33,11 +37,20 @@ function hermesDevToken(): Plugin {
);
return;
}
const embeddedMatch = html.match(EMBEDDED_RE);
const legacyMatch = html.match(LEGACY_TUI_RE);
const embeddedJs = embeddedMatch
? embeddedMatch[1]
: legacyMatch
? legacyMatch[1]
: "false";
return [
{
tag: "script",
injectTo: "head",
children: `window.__HERMES_SESSION_TOKEN__="${match[1]}";`,
children:
`window.__HERMES_SESSION_TOKEN__="${match[1]}";` +
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};`,
},
];
} catch (err) {