diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 1db1c2aaa0d..26a4e2ce7c8 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -1,10 +1,10 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd } from '@/lib/desktop-fs' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, applyDesktopBootProgress, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 29b6cbd80c8..d6c9ab0e029 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -1,8 +1,8 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, setConnection } from '@/store/session' diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts index 2884f08d29a..e8b09765923 100644 --- a/apps/desktop/src/lib/gateway-ws-url.test.ts +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -1,7 +1,6 @@ +import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { describe, expect, it, vi } from 'vitest' -import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url' - const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' } const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' } diff --git a/apps/desktop/src/lib/gateway-ws-url.ts b/apps/desktop/src/lib/gateway-ws-url.ts deleted file mode 100644 index db483be7137..00000000000 --- a/apps/desktop/src/lib/gateway-ws-url.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { HermesConnection } from '@/global' - -/** - * The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket - * URL immediately before every `gateway.connect()`. For OAuth-gated remote - * gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked - * into the cached `conn.wsUrl` is stale (and, after the first connect, already - * consumed). For local/token gateways the URL carries a long-lived token and - * never needs re-minting. - * - * Resolution rules: - * - * - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall - * back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to - * fail with an opaque "connection closed" error. Instead, let the mint error - * propagate so the caller can surface the gateway's reauth message - * ("session has expired… Sign in again"). - * - * - token / local, or when the preload method is genuinely absent (older - * preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so - * the fallback is safe and preserves compatibility. - * - * The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so - * callers can distinguish "the user must re-authenticate" from a generic - * transport failure. - */ -export interface ResolveGatewayWsUrlDeps { - /** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The - * optional profile selects which backend to mint for — critical when swapping - * to a pooled profile, since the default mint resolves the primary backend. */ - getGatewayWsUrl?: (profile?: null | string) => Promise -} - -export class GatewayReauthRequiredError extends Error { - readonly needsOauthLogin = true - - constructor(message: string, options?: { cause?: unknown }) { - super(message, options) - this.name = 'GatewayReauthRequiredError' - } -} - -export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError { - return ( - error instanceof GatewayReauthRequiredError || - (typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true) - ) -} - -export async function resolveGatewayWsUrl( - desktop: ResolveGatewayWsUrlDeps, - conn: Pick -): Promise { - const mint = desktop.getGatewayWsUrl - // Mint for THIS connection's profile, not the primary. Without it a pooled - // profile swap re-mints the default backend's URL and connects to the wrong - // backend. - const profile = conn.profile ?? null - - if (conn.authMode === 'oauth') { - if (!mint) { - // OAuth gateway but no way to mint a fresh ticket: the cached ticket is - // dead, so connecting with it cannot succeed. Surface a reauth error - // rather than silently attempting a doomed connect. - throw new GatewayReauthRequiredError( - 'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.' - ) - } - - try { - return await mint(profile) - } catch (error) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', - { cause: error } - ) - } - } - - // token / local: the URL carries a long-lived token. Re-mint when available - // (cheap, keeps parity), but the cached URL is a safe fallback. - if (mint) { - const fresh = await mint(profile).catch(() => null) - - if (fresh) { - return fresh - } - } - - return conn.wsUrl -} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 8cc8efedd4a..ee51119dd78 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -1,8 +1,7 @@ -import type { ConnectionState, GatewayEvent } from '@hermes/shared' +import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared' import { atom } from 'nanostores' import { HermesGateway } from '@/hermes' -import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { setGatewayState } from '@/store/session' // ── Multi-profile gateway routing ────────────────────────────────────────── diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 3a900ee488e..50f9936bfb3 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -8,3 +8,14 @@ export { type JsonRpcFrame, type WebSocketLike } from './json-rpc-gateway' +export { + GatewayReauthRequiredError, + buildHermesWebSocketUrl, + isGatewayReauthRequired, + resolveGatewayWsUrl, + type GatewayAuthMode, + type GatewayWsConnection, + type HermesWebSocketUrlOptions, + type ResolveGatewayWsUrlDeps, + type WebSocketAuthParam +} from './websocket-url' diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts new file mode 100644 index 00000000000..a4641399534 --- /dev/null +++ b/apps/shared/src/websocket-url.ts @@ -0,0 +1,123 @@ +export type GatewayAuthMode = 'oauth' | 'token' | (string & {}) + +export interface GatewayWsConnection { + authMode?: GatewayAuthMode | null + profile?: null | string + wsUrl: string +} + +export interface ResolveGatewayWsUrlDeps { + /** + * Returns a fresh WebSocket URL for the selected backend/profile. + * OAuth-gated gateways use single-use tickets, so callers should mint + * immediately before opening the socket. + */ + getGatewayWsUrl?: (profile?: null | string) => Promise +} + +export class GatewayReauthRequiredError extends Error { + readonly needsOauthLogin = true + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'GatewayReauthRequiredError' + } +} + +export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError { + return ( + error instanceof GatewayReauthRequiredError || + (typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true) + ) +} + +export async function resolveGatewayWsUrl( + deps: ResolveGatewayWsUrlDeps, + conn: GatewayWsConnection +): Promise { + const mint = deps.getGatewayWsUrl + const profile = conn.profile ?? null + + if (conn.authMode === 'oauth') { + if (!mint) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.' + ) + } + + try { + return await mint(profile) + } catch (error) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: error } + ) + } + } + + if (mint) { + const fresh = await mint(profile).catch(() => null) + + if (fresh) { + return fresh + } + } + + return conn.wsUrl +} + +export type WebSocketAuthParam = readonly [name: string, value: string] + +export interface HermesWebSocketUrlOptions { + /** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */ + path: string + /** Optional URL prefix when the backend is reverse-proxied below a subpath. */ + basePath?: string + /** Query auth pair, usually ["token", value] or ["ticket", value]. */ + authParam?: WebSocketAuthParam + /** Extra query params merged before auth. */ + params?: Record + /** Browser protocol string such as "https:"; defaults to window.location.protocol. */ + protocol?: string + /** Host with optional port; defaults to window.location.host. */ + host?: string +} + +function readWindowLocation(): { host: string; protocol: string } { + if (typeof window === 'undefined') { + return { host: '', protocol: 'http:' } + } + + return { host: window.location.host, protocol: window.location.protocol } +} + +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath) { + return '' + } + + const withLead = basePath.startsWith('/') ? basePath : `/${basePath}` + return withLead.replace(/\/+$/, '') +} + +function normalizeEndpointPath(path: string): string { + return path.startsWith('/') ? path : `/${path}` +} + +export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string { + const loc = readWindowLocation() + const protocol = options.protocol ?? loc.protocol + const host = options.host ?? loc.host + const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:' + const qs = new URLSearchParams(options.params ?? {}) + + if (options.authParam) { + const [name, value] = options.authParam + qs.set(name, value) + } + + const query = qs.toString() + const suffix = query ? `?${query}` : '' + + return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}` +} diff --git a/package-lock.json b/package-lock.json index 2fe3537733e..ec901a54115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19571,6 +19571,7 @@ "web": { "version": "0.0.0", "dependencies": { + "@hermes/shared": "file:../apps/shared", "@nous-research/ui": "0.18.2", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", diff --git a/web/package.json b/web/package.json index 6666773c737..0e9987b51b3 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "@hermes/shared": "file:../apps/shared", "@nous-research/ui": "0.18.2", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 03eadf59288..d924c6d2ee4 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -26,6 +26,7 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card } from "@nous-research/ui/ui/components/card"; +import { buildHermesWebSocketUrl } from "@hermes/shared"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; @@ -230,14 +231,17 @@ export function ChatSidebar({ let unmounting = false; let ws: WebSocket | null = null; void (async () => { - const [authName, authValue] = await buildWsAuthParam(); - if (!authValue || unmounting) { + const authParam = await buildWsAuthParam(); + if (!authParam[1] || unmounting) { return; } - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams({ [authName]: authValue, channel }); ws = new WebSocket( - `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`, + buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + params: { channel }, + path: "/api/events", + }), ); // `unmounting` suppresses the banner during cleanup — `ws.close()` diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 983baffae0f..81643f3ac5c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { buildHermesWebSocketUrl } from "@hermes/shared"; + // The dashboard can be served either at the root of its host (e.g. // https://kanban.tilos.com/) or under a URL prefix when reverse-proxied // (e.g. https://mission-control.tilos.com/hermes/). The Python backend @@ -291,11 +293,12 @@ export async function buildWsUrl( path: string, params?: Record, ): Promise { - const [authName, authValue] = await buildWsAuthParam(); - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams(params ?? {}); - qs.set(authName, authValue); - return `${proto}//${window.location.host}${BASE}${path}?${qs}`; + return buildHermesWebSocketUrl({ + authParam: await buildWsAuthParam(), + basePath: BASE, + params, + path, + }); } /** Build a ``?profile=`` query suffix, or "" when unset. diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 16b31ae68a0..325c8cd2bd1 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -13,241 +13,53 @@ * await gw.request("prompt.submit", { session_id, text: "hi" }) */ -import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api"; +import { + JsonRpcGatewayClient, + buildHermesWebSocketUrl, + type ConnectionState, + type GatewayEvent, + type GatewayEventName, +} from "@hermes/shared"; -export type GatewayEventName = - | "gateway.ready" - | "session.info" - | "message.start" - | "message.delta" - | "message.complete" - | "thinking.delta" - | "reasoning.delta" - | "reasoning.available" - | "status.update" - | "tool.start" - | "tool.progress" - | "tool.complete" - | "tool.generating" - | "clarify.request" - | "approval.request" - | "sudo.request" - | "secret.request" - | "background.complete" - | "error" - | "skin.changed" - | (string & {}); +import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; -export interface GatewayEvent

{ - type: GatewayEventName; - session_id?: string; - payload?: P; -} +export type { ConnectionState, GatewayEvent, GatewayEventName }; -export type ConnectionState = - | "idle" - | "connecting" - | "open" - | "closed" - | "error"; - -interface Pending { - resolve: (v: unknown) => void; - reject: (e: Error) => void; - timer: ReturnType; -} - -const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; - -/** Wildcard listener key: subscribe to every event regardless of type. */ -const ANY = "*"; - -export class GatewayClient { - private ws: WebSocket | null = null; - private reqId = 0; - private pending = new Map(); - private listeners = new Map void>>(); - private _state: ConnectionState = "idle"; - private stateListeners = new Set<(s: ConnectionState) => void>(); +export class GatewayClient extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: "WebSocket closed", + connectErrorMessage: "WebSocket connection failed", + notConnectedErrorMessage: "gateway not connected", + requestIdPrefix: "w", + }); + } get state(): ConnectionState { - return this._state; - } - - private setState(s: ConnectionState) { - if (this._state === s) return; - this._state = s; - for (const cb of this.stateListeners) cb(s); - } - - onState(cb: (s: ConnectionState) => void): () => void { - this.stateListeners.add(cb); - cb(this._state); - return () => this.stateListeners.delete(cb); - } - - /** Subscribe to a specific event type. Returns an unsubscribe function. */ - on

( - type: GatewayEventName, - cb: (ev: GatewayEvent

) => void, - ): () => void { - let set = this.listeners.get(type); - if (!set) { - set = new Set(); - this.listeners.set(type, set); - } - set.add(cb as (ev: GatewayEvent) => void); - return () => set!.delete(cb as (ev: GatewayEvent) => void); - } - - /** Subscribe to every event (fires after type-specific listeners). */ - onAny(cb: (ev: GatewayEvent) => void): () => void { - return this.on(ANY as GatewayEventName, cb); + return this.connectionState; } async connect(token?: string): Promise { - if (this._state === "open" || this._state === "connecting") return; - this.setState("connecting"); - - // Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the - // SPA must fetch a single-use ticket via /api/auth/ws-ticket instead. - // Explicit ``token`` overrides the gate check (test-only path). - let authParamName: string; - let authParamValue: string; - if (token) { - authParamName = "token"; - authParamValue = token; - } else if (window.__HERMES_AUTH_REQUIRED__) { - const { ticket } = await getWsTicket(); - authParamName = "ticket"; - authParamValue = ticket; - } else { - authParamName = "token"; - authParamValue = window.__HERMES_SESSION_TOKEN__ ?? ""; - if (!authParamValue) { - this.setState("error"); - throw new Error( - "Session token not available — page must be served by the Hermes dashboard", - ); - } - } - - const scheme = location.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket( - `${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`, - ); - this.ws = ws; - - // Register message + close BEFORE awaiting open — the server emits - // `gateway.ready` immediately after accept, so a listener attached - // after the open promise resolves can race past it and drop the - // initial skin payload. - ws.addEventListener("message", (ev) => { - try { - this.dispatch(JSON.parse(ev.data)); - } catch { - /* malformed frame — ignore */ - } - }); - - ws.addEventListener("close", () => { - this.setState("closed"); - this.rejectAllPending(new Error("WebSocket closed")); - }); - - await new Promise((resolve, reject) => { - const onOpen = () => { - ws.removeEventListener("error", onError); - this.setState("open"); - resolve(); - }; - const onError = () => { - ws.removeEventListener("open", onOpen); - this.setState("error"); - reject(new Error("WebSocket connection failed")); - }; - ws.addEventListener("open", onOpen, { once: true }); - ws.addEventListener("error", onError, { once: true }); - }); - } - - close() { - this.ws?.close(); - this.ws = null; - } - - private dispatch(msg: Record) { - const id = msg.id as string | undefined; - - if (id !== undefined && this.pending.has(id)) { - const p = this.pending.get(id)!; - this.pending.delete(id); - clearTimeout(p.timer); - - const err = msg.error as { message?: string } | undefined; - if (err) p.reject(new Error(err.message ?? "request failed")); - else p.resolve(msg.result); + if (this.connectionState === "open" || this.connectionState === "connecting") { return; } - if (msg.method !== "event") return; - - const params = (msg.params ?? {}) as GatewayEvent; - if (typeof params.type !== "string") return; - - for (const cb of this.listeners.get(params.type) ?? []) cb(params); - for (const cb of this.listeners.get(ANY) ?? []) cb(params); - } - - private rejectAllPending(err: Error) { - for (const p of this.pending.values()) { - clearTimeout(p.timer); - p.reject(err); - } - this.pending.clear(); - } - - /** Send a JSON-RPC request. Rejects on error response or timeout. */ - request( - method: string, - params: Record = {}, - timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - ): Promise { - if (!this.ws || this._state !== "open") { - return Promise.reject( - new Error(`gateway not connected (state=${this._state})`), + // Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA + // must fetch a single-use ticket. Explicit ``token`` keeps the test-only + // override path. + const authParam = token ? (["token", token] as const) : await buildWsAuthParam(); + if (!authParam[1]) { + throw new Error( + "Session token not available — page must be served by the Hermes dashboard server", ); } - const id = `w${++this.reqId}`; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (this.pending.delete(id)) { - reject(new Error(`request timed out: ${method}`)); - } - }, timeoutMs); - - this.pending.set(id, { - resolve: (v) => resolve(v as T), - reject, - timer, - }); - - try { - this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); - } catch (e) { - clearTimeout(timer); - this.pending.delete(id); - reject(e instanceof Error ? e : new Error(String(e))); - } - }); - } -} - -declare global { - interface Window { - __HERMES_SESSION_TOKEN__?: string; - __HERMES_AUTH_REQUIRED__?: boolean; + await super.connect( + buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + path: "/api/ws", + }), + ); } } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ad52214c980..202fedbf8e7 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -24,6 +24,7 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@nous-research/ui/ui/components/typography/index"; +import { buildHermesWebSocketUrl, type WebSocketAuthParam } from "@hermes/shared"; import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, RotateCcw, X } from "lucide-react"; @@ -42,24 +43,28 @@ import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; function buildWsUrl( - authParam: [string, string], + authParam: WebSocketAuthParam, resume: string | null, channel: string, profile: string, fresh: boolean, ): string { - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; // ``authParam`` is ``["token", ]`` in loopback mode and // ``["ticket", ]`` in gated mode. The server-side helper // ``_ws_auth_ok`` picks whichever shape matches the current gate state. - const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel }); - if (resume) qs.set("resume", resume); - if (fresh) qs.set("fresh", "1"); + const params: Record = { channel }; + if (resume) params.resume = resume; + if (fresh) params.fresh = "1"; // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the // selected profile, so the conversation runs with that profile's model, // skills, memory, and sessions (see web_server._resolve_chat_argv). - if (profile) qs.set("profile", profile); - return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`; + if (profile) params.profile = profile; + return buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + params, + path: "/api/pty", + }); } // Channel id ties this chat tab's PTY child (publisher) to its sidebar @@ -767,7 +772,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } if (ev.code === 4404) { setBanner( - "Embedded chat is disabled on this server (start it with --tui).", + ev.reason + ? `Chat websocket unavailable: ${ev.reason}.` + : "Chat websocket unavailable on this server.", ); return; } diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index ed6ca708523..5e743884fb0 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -18,7 +18,8 @@ /* Path aliases */ "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@hermes/shared": ["../apps/shared/src/index.ts"] }, /* Linting */ @@ -29,5 +30,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "../apps/shared/src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index fc92eb924ce..6521751af96 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@hermes/shared": path.resolve(__dirname, "../apps/shared/src"), }, // When @nous-research/ui is symlinked via `file:../../design-language`, // Node's module resolution would pick up shared deps from