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: try:
import fastapi # noqa: F401 import fastapi # noqa: F401
import uvicorn # noqa: F401 import uvicorn # noqa: F401
except ImportError: except ImportError as e:
print("Web UI dependencies not installed.") print("Web UI dependencies not installed (need fastapi + uvicorn).")
print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'") 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) sys.exit(1)
if "HERMES_WEB_DIST" not in os.environ: if "HERMES_WEB_DIST" not in os.environ:
@ -6726,11 +6732,13 @@ def cmd_dashboard(args):
from hermes_cli.web_server import start_server from hermes_cli.web_server import start_server
embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
start_server( start_server(
host=args.host, host=args.host,
port=args.port, port=args.port,
open_browser=not args.no_open, open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False), allow_public=getattr(args, "insecure", False),
embedded_chat=embedded_chat,
) )
@ -8916,6 +8924,14 @@ Examples:
action="store_true", action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", 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) dashboard_parser.set_defaults(func=cmd_dashboard)
# ========================================================================= # =========================================================================

View file

@ -96,10 +96,18 @@ class PtyBridge:
ordinary exec failures (missing binary, bad cwd, etc.). ordinary exec failures (missing binary, bad cwd, etc.).
""" """
if not _PTY_AVAILABLE: if not _PTY_AVAILABLE:
raise PtyUnavailableError( if sys.platform.startswith("win"):
"Pseudo-terminals are unavailable on this platform. " raise PtyUnavailableError(
"Hermes Agent supports Windows only via WSL." "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 # Let caller-supplied env fully override inheritance; if they pass
# None we inherit the server's env (same semantics as subprocess). # None we inherit the server's env (same semantics as subprocess).
spawn_env = os.environ.copy() if env is None else env 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_TOKEN = secrets.token_urlsafe(32)
_SESSION_HEADER_NAME = "X-Hermes-Session-Token" _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 # Simple rate limiter for the reveal endpoint
_reveal_timestamps: List[float] = [] _reveal_timestamps: List[float] = []
_REVEAL_MAX_PER_WINDOW = 5 _REVEAL_MAX_PER_WINDOW = 5
@ -2370,6 +2374,10 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
@app.websocket("/api/pty") @app.websocket("/api/pty")
async def pty_ws(ws: WebSocket) -> None: 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) --- # --- auth + loopback check (before accept so we can close cleanly) ---
token = ws.query_params.get("token", "") token = ws.query_params.get("token", "")
expected = _SESSION_TOKEN expected = _SESSION_TOKEN
@ -2476,6 +2484,10 @@ async def pty_ws(ws: WebSocket) -> None:
@app.websocket("/api/ws") @app.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None: 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", "") token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401) await ws.close(code=4401)
@ -2505,6 +2517,10 @@ async def gateway_ws(ws: WebSocket) -> None:
@app.websocket("/api/pub") @app.websocket("/api/pub")
async def pub_ws(ws: WebSocket) -> None: 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", "") token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401) await ws.close(code=4401)
@ -2531,6 +2547,10 @@ async def pub_ws(ws: WebSocket) -> None:
@app.websocket("/api/events") @app.websocket("/api/events")
async def events_ws(ws: WebSocket) -> None: 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", "") token = ws.query_params.get("token", "")
if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
await ws.close(code=4401) await ws.close(code=4401)
@ -2591,8 +2611,10 @@ def mount_spa(application: FastAPI):
def _serve_index(): def _serve_index():
"""Return index.html with the session token injected.""" """Return index.html with the session token injected."""
html = _index_path.read_text() html = _index_path.read_text()
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
token_script = ( 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) html = html.replace("</head>", f"{token_script}</head>", 1)
return HTMLResponse( return HTMLResponse(
@ -3105,10 +3127,15 @@ def start_server(
port: int = 9119, port: int = 9119,
open_browser: bool = True, open_browser: bool = True,
allow_public: bool = False, allow_public: bool = False,
*,
embedded_chat: bool = False,
): ):
"""Start the web UI server.""" """Start the web UI server."""
import uvicorn import uvicorn
global _DASHBOARD_EMBEDDED_CHAT_ENABLED
_DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat
_LOCALHOST = ("127.0.0.1", "localhost", "::1") _LOCALHOST = ("127.0.0.1", "localhost", "::1")
if host not in _LOCALHOST and not allow_public: if host not in _LOCALHOST and not allow_public:
raise SystemExit( raise SystemExit(

View file

@ -34,6 +34,11 @@ dependencies = [
"edge-tts>=7.2.7,<8", "edge-tts>=7.2.7,<8",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 "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] [project.optional-dependencies]
@ -78,6 +83,7 @@ termux = [
] ]
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"] 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"] 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"] web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [ rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", "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 # Avoid exec'ing the actual TUI in tests: every test below installs
# its own fake argv via ``ws._resolve_chat_argv``. # its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
self.token = ws._SESSION_TOKEN self.token = ws._SESSION_TOKEN
self.client = TestClient(ws.app) self.client = TestClient(ws.app)
@ -1719,6 +1720,15 @@ class TestPtyWebSocket:
q = {"token": tok, **params} q = {"token": tok, **params}
return f"/api/pty?{urlencode(q)}" 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): def test_rejects_missing_token(self, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
self.ws_module, self.ws_module,

10
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
] ]
[options] [options]
exclude-newer = "2026-04-16T11:49:00.318115Z" exclude-newer = "2026-04-17T15:09:44.835508886Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[[package]] [[package]]
@ -1870,13 +1870,14 @@ wheels = [
[[package]] [[package]]
name = "hermes-agent" name = "hermes-agent"
version = "0.10.0" version = "0.11.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anthropic" }, { name = "anthropic" },
{ name = "edge-tts" }, { name = "edge-tts" },
{ name = "exa-py" }, { name = "exa-py" },
{ name = "fal-client" }, { name = "fal-client" },
{ name = "fastapi" },
{ name = "fire" }, { name = "fire" },
{ name = "firecrawl-py" }, { name = "firecrawl-py" },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["socks"] },
@ -1884,6 +1885,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "parallel-web" }, { name = "parallel-web" },
{ name = "prompt-toolkit" }, { name = "prompt-toolkit" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@ -1891,6 +1893,7 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
{ name = "rich" }, { name = "rich" },
{ name = "tenacity" }, { name = "tenacity" },
{ name = "uvicorn", extra = ["standard"] },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -2059,6 +2062,7 @@ requires-dist = [
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
{ name = "exa-py", specifier = ">=2.9.0,<3" }, { name = "exa-py", specifier = ">=2.9.0,<3" },
{ name = "fal-client", specifier = ">=0.13.1,<1" }, { 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 == 'rl'", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'web'", 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" }, { 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 = "openai", specifier = ">=2.21.0,<3" },
{ name = "parallel-web", specifier = ">=0.4.2,<1" }, { name = "parallel-web", specifier = ">=0.4.2,<1" },
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, { 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 = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
{ name = "pydantic", specifier = ">=2.12.5,<3" }, { name = "pydantic", specifier = ">=2.12.5,<3" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
@ -2131,6 +2136,7 @@ requires-dist = [
{ name = "tenacity", specifier = ">=9.1.4,<10" }, { name = "tenacity", specifier = ">=9.1.4,<10" },
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, { 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 = "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 == 'rl'", specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", 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" }, { 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 { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins"; import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes"; import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
function RootRedirect() { function RootRedirect() {
return <Navigate to="/sessions" replace />; return <Navigate to="/sessions" replace />;
} }
/** Built-in route → page component. Used for routing and for plugin `tab.path` / `tab.override` resolution. */ const CHAT_NAV_ITEM: NavItem = {
const BUILTIN_ROUTES: Record<string, ComponentType> = { 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, "/": RootRedirect,
"/chat": ChatPage,
"/sessions": SessionsPage, "/sessions": SessionsPage,
"/analytics": AnalyticsPage, "/analytics": AnalyticsPage,
"/logs": LogsPage, "/logs": LogsPage,
@ -84,8 +91,7 @@ const BUILTIN_ROUTES: Record<string, ComponentType> = {
"/docs": DocsPage, "/docs": DocsPage,
}; };
const BUILTIN_NAV: NavItem[] = [ const BUILTIN_NAV_REST: NavItem[] = [
{ path: "/chat", labelKey: "chat", label: "Chat", icon: Terminal },
{ {
path: "/sessions", path: "/sessions",
labelKey: "sessions", labelKey: "sessions",
@ -170,7 +176,10 @@ function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem
return items; return items;
} }
function buildRoutes(manifests: PluginManifest[]): Array<{ function buildRoutes(
builtinRoutes: Record<string, ComponentType>,
manifests: PluginManifest[],
): Array<{
key: string; key: string;
path: string; path: string;
element: ReactNode; element: ReactNode;
@ -192,7 +201,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
element: ReactNode; element: ReactNode;
}> = []; }> = [];
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) { for (const [path, Component] of Object.entries(builtinRoutes)) {
const om = byOverride.get(path); const om = byOverride.get(path);
if (om) { if (om) {
routes.push({ routes.push({
@ -207,7 +216,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
for (const m of addons) { for (const m of addons) {
if (m.tab.hidden) continue; if (m.tab.hidden) continue;
if (BUILTIN_ROUTES[m.tab.path]) continue; if (builtinRoutes[m.tab.path]) continue;
routes.push({ routes.push({
key: `plugin:${m.name}`, key: `plugin:${m.name}`,
path: m.tab.path, path: m.tab.path,
@ -217,7 +226,7 @@ function buildRoutes(manifests: PluginManifest[]): Array<{
for (const m of manifests) { for (const m of manifests) {
if (!m.tab.hidden) continue; 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({ routes.push({
key: `plugin:hidden:${m.name}`, key: `plugin:hidden:${m.name}`,
path: m.tab.path, path: m.tab.path,
@ -236,12 +245,32 @@ export default function App() {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []); const closeMobile = useCallback(() => setMobileOpen(false), []);
const isDocsRoute = pathname === "/docs" || pathname === "/docs/"; 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( const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, manifests), () => buildNavItems(builtinNav, manifests),
[manifests], [builtinNav, manifests],
);
const routes = useMemo(
() => buildRoutes(builtinRoutes, manifests),
[builtinRoutes, manifests],
); );
const routes = useMemo(() => buildRoutes(manifests), [manifests]);
const pluginTabMeta = useMemo( const pluginTabMeta = useMemo(
() => () =>
manifests manifests
@ -468,8 +497,9 @@ export default function App() {
className={cn( className={cn(
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
"px-3 sm:px-6", "px-3 sm:px-6",
"pt-2 sm:pt-4 lg:pt-6", isChatRoute
"pb-4 sm:pb-8", ? "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", isDocsRoute && "min-h-0 flex-1",
)} )}
> >
@ -477,7 +507,7 @@ export default function App() {
<div <div
className={cn( className={cn(
"w-full min-w-0", "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> <Routes>

View file

@ -31,6 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@ -66,9 +67,10 @@ const STATE_TONE: Record<ConnectionState, string> = {
interface ChatSidebarProps { interface ChatSidebarProps {
channel: string; 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 // `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 // 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, // 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; const banner = error ?? info.credential_warning ?? null;
return ( 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"> <Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground"> <div className="text-xs uppercase tracking-wider text-muted-foreground">

View file

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

View file

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

View file

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

View file

@ -52,6 +52,7 @@ export const zh: Translations = {
brand: "Hermes Agent", brand: "Hermes Agent",
brandShort: "HA", brandShort: "HA",
closeNavigation: "关闭导航", closeNavigation: "关闭导航",
closeModelTools: "关闭模型与工具",
footer: { footer: {
org: "Nous Research", org: "Nous Research",
}, },
@ -75,6 +76,8 @@ export const zh: Translations = {
sessions: "会话", sessions: "会话",
skills: "技能", skills: "技能",
}, },
modelToolsSheetSubtitle: "与工具",
modelToolsSheetTitle: "模型",
navigation: "导航", navigation: "导航",
openDocumentation: "在新标签页中打开文档", openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航", 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 { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { Copy } from "lucide-react"; import { Typography } from "@nous-research/ui";
import { useEffect, useMemo, useRef, useState } from "react"; 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 { useSearchParams } from "react-router-dom";
import { ChatSidebar } from "@/components/ChatSidebar"; import { ChatSidebar } from "@/components/ChatSidebar";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
function buildWsUrl( function buildWsUrl(
token: string, token: string,
@ -62,6 +67,39 @@ const TERMINAL_THEME = {
selectionBackground: "#f0e6d244", 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() { export default function ChatPage() {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
@ -77,10 +115,83 @@ export default function ChatPage() {
); );
const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null); 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 resumeRef = useRef<string | null>(searchParams.get("resume"));
const channel = useMemo(() => generateChannelId(), []); 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 handleCopyLast = () => {
const ws = wsRef.current; const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
@ -110,13 +221,17 @@ export default function ChatPage() {
return; return;
} }
const tierW0 = terminalTierWidthPx(host);
const term = new Terminal({ const term = new Terminal({
allowProposedApi: true, allowProposedApi: true,
cursorBlink: true, cursorBlink: true,
fontFamily: fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontSize: 14, fontSize: terminalFontSizeForWidth(tierW0),
lineHeight: 1.2, lineHeight: terminalLineHeightForWidth(tierW0),
letterSpacing: 0,
fontWeight: "400",
fontWeightBold: "700",
macOptionIsMeta: true, macOptionIsMeta: true,
scrollback: 0, scrollback: 0,
theme: TERMINAL_THEME, theme: TERMINAL_THEME,
@ -212,19 +327,23 @@ export default function ChatPage() {
term.open(host); term.open(host);
// WebGL renderer: rasterizes glyphs to a GPU texture atlas, paints // WebGL draws from a texture atlas sized with device pixels. On phones and
// each cell at an integer-pixel position. Box-drawing glyphs connect // in DevTools device mode that often produces *visually* much larger cells
// cleanly between rows (no DOM baseline / line-height math). Falls // than `fontSize` suggests — users see "huge" text even at 79px settings.
// back to the default DOM renderer if WebGL is unavailable. // The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
try { // hosts. Wide layouts still get WebGL for crisp box-drawing.
const webgl = new WebglAddon(); const useWebgl = terminalTierWidthPx(host) >= 768;
webgl.onContextLoss(() => webgl.dispose()); if (useWebgl) {
term.loadAddon(webgl); try {
} catch (err) { const webgl = new WebglAddon();
console.warn( webgl.onContextLoss(() => webgl.dispose());
"[hermes-chat] WebGL renderer unavailable; falling back to default", term.loadAddon(webgl);
err, } catch (err) {
); console.warn(
"[hermes-chat] WebGL renderer unavailable; falling back to default",
err,
);
}
} }
// Initial fit + resize observer. fit.fit() reads the container's // 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 // frames. rAF→rAF guarantees one layout commit between the two
// callbacks, giving CSS transitions and font metrics time to finalize // callbacks, giving CSS transitions and font metrics time to finalize
// before we take the authoritative measurement. // before we take the authoritative measurement.
let rafId = 0; let hostSyncRaf = 0;
const scheduleFit = () => { const scheduleHostSync = () => {
if (rafId) return; if (hostSyncRaf) return;
rafId = requestAnimationFrame(() => { hostSyncRaf = requestAnimationFrame(() => {
rafId = 0; hostSyncRaf = 0;
try { syncTerminalMetrics();
fit.fit();
} catch {
// Element was removed mid-resize; cleanup will handle it.
}
}); });
}; };
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); 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 // Double-rAF authoritative fit. On the second frame the layout has
// committed at least once since mount; fit.fit() then reads the // committed at least once since mount; fit.fit() then reads the
// stable container size. We always send a RESIZE escape afterwards // stable container size. We always send a RESIZE escape afterwards
@ -272,15 +434,7 @@ export default function ChatPage() {
settleRaf1 = 0; settleRaf1 = 0;
settleRaf2 = requestAnimationFrame(() => { settleRaf2 = requestAnimationFrame(() => {
settleRaf2 = 0; settleRaf2 = 0;
try { syncTerminalMetrics();
fit.fit();
} catch {
return;
}
const sock = wsRef.current;
if (sock && sock.readyState === WebSocket.OPEN) {
sock.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
}
}); });
}); });
@ -387,8 +541,18 @@ export default function ChatPage() {
return () => { return () => {
onDataDisposable.dispose(); onDataDisposable.dispose();
onResizeDisposable.dispose(); onResizeDisposable.dispose();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
"resize",
scheduleSyncTerminalMetrics,
);
window.visualViewport?.removeEventListener(
"scroll",
scheduleSyncTerminalMetrics,
);
ro.disconnect(); ro.disconnect();
if (rafId) cancelAnimationFrame(rafId); if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1); if (settleRaf1) cancelAnimationFrame(settleRaf1);
if (settleRaf2) cancelAnimationFrame(settleRaf2); if (settleRaf2) cancelAnimationFrame(settleRaf2);
ws.close(); ws.close();
@ -416,52 +580,149 @@ export default function ChatPage() {
// //
// `normal-case` opts out of the dashboard's global `uppercase` rule on // `normal-case` opts out of the dashboard's global `uppercase` rule on
// the root `<div>` in App.tsx — terminal output must preserve case. // 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 ( 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 && ( {banner && (
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide"> <div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
{banner} {banner}
</div> </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 <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={{ style={{
backgroundColor: TERMINAL_THEME.background, backgroundColor: TERMINAL_THEME.background,
padding: "12px",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)", 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 <button
type="button" type="button"
onClick={handleCopyLast} onClick={handleCopyLast}
title="Copy last assistant response as raw markdown" title="Copy last assistant response as raw markdown"
aria-label="Copy last assistant response" aria-label="Copy last assistant response"
className={[ className={cn(
"absolute bottom-4 right-4 z-10", "absolute z-10 flex items-center gap-1.5",
"flex items-center gap-1.5",
"rounded border border-current/30", "rounded border border-current/30",
"bg-black/20 backdrop-blur-sm", "bg-black/20 backdrop-blur-sm",
"px-2.5 py-1.5 text-xs",
"opacity-60 hover:opacity-100 hover:border-current/60", "opacity-60 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150", "transition-opacity duration-150",
"focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current", "focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current",
"cursor-pointer", "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 }} style={{ color: TERMINAL_THEME.foreground }}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3 shrink-0" />
<span className="tracking-wide"> <span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"} {copyState === "copied" ? "copied" : "copy last response"}
</span> </span>
</button> </button>
</div> </div>
<div className="hidden min-h-0 lg:block"> {!narrow && (
<ChatSidebar channel={channel} /> <div
</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>
</div> </div>
); );

View file

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

View file

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

View file

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