/** * ChatSidebar — structured-events panel that sits next to the xterm.js * terminal in the dashboard Chat tab. * * Two WebSockets, one per concern: * * 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — a lightweight * session used only for connection state (the "live" badge) and * credential warnings. Independent of the PTY pane's session by * design. The model badge does NOT come from here: it reads the * effective config model over REST (`/api/model/info`), and the model * picker writes config over REST (`/api/model/set`) then offers a * dashboard reload so the running chat adopts the new model. * * 2. **Event subscriber** (/api/events?channel=…) — passive, receives * every dispatcher emit from the PTY-side `tui_gateway.entry` that * the dashboard fanned out. The sidebar uses it for `session.info` * (live chat title) and `dashboard.new_session_requested`. The * `channel` id ties this listener to the same chat tab's PTY child — * see `ChatPage.tsx` for where the id is generated. * * Best-effort throughout: WS failures show in the badge / banner, the * terminal pane keeps working unimpaired. */ 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 { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; import { ReasoningPicker } from "@/components/ReasoningPicker"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { titleFromSessionInfoPayload } from "@/lib/chat-title"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface SessionInfo { cwd?: string; model?: string; provider?: string; credential_warning?: string; title?: string; } interface RpcEnvelope { method?: string; params?: { type?: string; payload?: unknown }; } const STATE_LABEL: Record = { idle: "idle", connecting: "connecting", open: "live", closed: "closed", error: "error", }; const STATE_TONE: Record< ConnectionState, "secondary" | "warning" | "success" | "destructive" > = { idle: "secondary", connecting: "warning", open: "success", closed: "secondary", error: "destructive", }; interface ChatSidebarProps { channel: string; /** Management profile from the dashboard switcher — scopes session.create. */ profile?: string; className?: string; onDashboardNewSessionRequest?: () => void; onSessionTitleChange?: (title: string | null) => void; } export function ChatSidebar({ channel, profile, className, onDashboardNewSessionRequest, onSessionTitleChange, }: 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, // it's the signal that says "rebuild the client". const [version, setVersion] = useState(0); // eslint-disable-next-line react-hooks/exhaustive-deps const gw = useMemo(() => new GatewayClient(), [version]); const [state, setState] = useState("idle"); const [info, setInfo] = useState({}); const [modelOpen, setModelOpen] = useState(false); const [error, setError] = useState(null); // The badge shows config.yaml's main model (`model.default`) via // `/api/model/info` — the same value the Models page writes and a new chat // session boots from. We deliberately don't use the sidecar's `session.info` // model: that's a one-time snapshot of the throwaway sidecar agent taken when // its session is created, and it never updates when the model is changed // elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped // by `fetchJSON`, so it reads the same profile this sidebar is scoped to. const [effectiveModel, setEffectiveModel] = useState(""); // Whether the effective model supports reasoning effort — gates the // ReasoningPicker. Read from the same `/api/model/info` capabilities the // (currently unused) ModelInfoCard surfaces, so the dashboard exposes a // control to *set* the level, not just a read-only "Reasoning" badge. const [supportsReasoning, setSupportsReasoning] = useState(false); // Bumped on model change/save so ReasoningPicker re-reads the saved effort // (config is profile-scoped the same way the model badge is). const [modelRefreshKey, setModelRefreshKey] = useState(0); // Set after the picker saves a model and the user declines the reload: config // is updated but the running session keeps its model until rebuilt. const [modelNotice, setModelNotice] = useState(null); // Short name of a just-saved model awaiting confirm to reload (a fresh chat // session is how the running chat adopts it; we confirm before discarding it). const [pendingReloadModel, setPendingReloadModel] = useState( null, ); const refreshEffectiveModel = useCallback(() => { void api .getModelInfo() .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); setSupportsReasoning(!!r?.capabilities?.supports_reasoning); // Bump so ReasoningPicker re-reads the saved effort for the new model. setModelRefreshKey((k) => k + 1); }) .catch(() => { // Best-effort: keep the last known label rather than blanking it. }); }, []); // Profile or PTY channel change tears down both WebSockets. Bump `version` // (same path as the manual Reconnect button) so the gateway client is // recreated and the events feed resubscribes — otherwise the old events // socket's close handler can leave a stale error banner after a switch. const scopeKey = `${channel}\0${profile ?? ""}`; const prevScopeKey = useRef(null); useEffect(() => { if (prevScopeKey.current === null) { prevScopeKey.current = scopeKey; return; } if (prevScopeKey.current === scopeKey) return; prevScopeKey.current = scopeKey; setError(null); setVersion((v) => v + 1); }, [scopeKey]); useEffect(() => { let cancelled = false; queueMicrotask(() => { if (cancelled) return; setInfo({}); setError(null); }); const offState = gw.onState(setState); const offSessionInfo = gw.on("session.info", (ev) => { if (ev.payload) { setInfo((prev) => ({ ...prev, ...ev.payload })); } }); const offError = gw.on<{ message?: string }>("error", (ev) => { const message = ev.payload?.message; if (message) { setError(message); } }); // Create the sidecar session so the gateway surfaces session-scoped // signals (connection state, credential warnings). It's independent of the // PTY pane's session by design. The model picker no longer rides this // session — it writes config.yaml over REST — so we don't track its id. gw.connect() .then(() => { if (cancelled) { return; } // close_on_disconnect: the gateway reaps this sidecar session (and its // slash_worker subprocess) when the WS drops, instead of leaking it. return gw.request<{ session_id: string }>("session.create", { close_on_disconnect: true, source: "tool", ...(profile ? { profile } : {}), }); }) .catch((e: Error) => { if (!cancelled) { setError(e.message); } }); return () => { cancelled = true; offState(); offSessionInfo(); offError(); gw.close(); }; // `profile` is read from render; scope changes bump `version` → new `gw`. }, [gw]); // Event subscriber WebSocket — receives the rebroadcast of every // dispatcher emit from the PTY child's gateway. See /api/pub + // /api/events in hermes_cli/web_server.py for the broadcast hop. // // Failures (auth/loopback rejection, server too old to expose the // endpoint, transient drops) surface in the same banner as the // JSON-RPC sidecar so the sidebar matches its documented best-effort // UX and the user always has a reconnect affordance. useEffect(() => { if (!channel) { return; } // In loopback mode the legacy ?token= path is fine; in gated // mode we have to mint a single-use ticket from the cookie. The IIFE // keeps the outer effect synchronous so its ``return cleanup`` stays // at the top level; the local ``ws`` is hoisted to a closed-over // binding the cleanup reads via ``wsRef``. let unmounting = false; let ws: WebSocket | null = null; void (async () => { const [authName, authValue] = await buildWsAuthParam(); if (!authValue || 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()}`, ); // `unmounting` suppresses the banner during cleanup — `ws.close()` // from the effect's return fires a close event with code 1005 that // would otherwise look like an unexpected drop. const DISCONNECTED = "events feed disconnected — tool calls may not appear"; const surface = (msg: string) => !unmounting && setError(msg); ws.addEventListener("error", () => surface(DISCONNECTED)); ws.addEventListener("close", (ev) => { if (ev.code === 4401 || ev.code === 4403) { surface(`events feed rejected (${ev.code}) — reload the page`); } else if (ev.code !== 1000) { surface(DISCONNECTED); } }); ws.addEventListener("message", (ev) => { let frame: RpcEnvelope; try { frame = JSON.parse(ev.data); } catch { return; } if (frame.method !== "event" || !frame.params) { return; } const { type, payload } = frame.params; if (type === "session.info") { const title = titleFromSessionInfoPayload(payload); if (title !== undefined) { onSessionTitleChange?.(title); } } else if (type === "dashboard.new_session_requested") { onDashboardNewSessionRequest?.(); } }); })(); return () => { unmounting = true; ws?.close(); }; }, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]); // Seed the badge on mount and re-read it whenever the sockets are rebuilt // (a profile/channel switch bumps `version`). useEffect(() => { refreshEffectiveModel(); }, [refreshEffectiveModel, version]); const reconnect = useCallback(() => { setError(null); setModelNotice(null); setPendingReloadModel(null); setVersion((v) => v + 1); }, []); // The picker writes config.yaml over REST and reloads — it doesn't ride the // sidecar gateway session, so it's available whenever the sidebar is mounted. const modelName = effectiveModel || info.model || "—"; const modelLabel = modelName.split("/").slice(-1)[0] ?? "—"; const banner = error ?? info.credential_warning ?? null; return ( ); }