/** * 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) — drives the * sidebar's own slot of the dashboard's in-process gateway. Owns * the model badge / picker / connection state / error banner. * Independent of the PTY pane's session by design — those are the * pieces the sidebar needs to be able to drive directly (model * switch via slash.exec, etc.). * * 2. **Event subscriber** (/api/events?channel=…) — passive, receives * every dispatcher emit from the PTY-side `tui_gateway.entry` that * the dashboard fanned out. This is how `tool.start/progress/ * complete` from the agent loop reach the sidebar even though the * PTY child runs three processes deep from us. 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 { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; 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; } interface RpcEnvelope { method?: string; params?: { type?: string; payload?: unknown }; } const TOOL_LIMIT = 20; 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; } export function ChatSidebar({ channel, profile, 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, // 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 [sessionId, setSessionId] = useState(null); const [info, setInfo] = useState({}); const [tools, setTools] = useState([]); const [modelOpen, setModelOpen] = useState(false); const [error, setError] = useState(null); // 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); setTools([]); setVersion((v) => v + 1); }, [scopeKey]); useEffect(() => { let cancelled = false; setSessionId(null); setInfo({}); setError(null); const offState = gw.onState(setState); const offSessionInfo = gw.on("session.info", (ev) => { if (ev.session_id) { setSessionId(ev.session_id); } 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); } }); // Adopt whichever session the gateway hands us. session.create on the // sidecar is independent of the PTY pane's session by design — we // only need a sid to drive the model picker's slash.exec calls. 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, ...(profile ? { profile } : {}), }); }) .then((created) => { if (cancelled || !created?.session_id) { return; } setSessionId(created.session_id); }) .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 === "tool.start") { const p = payload as | { tool_id?: string; name?: string; context?: string } | undefined; const toolId = p?.tool_id; if (!toolId) { return; } setTools((prev) => [ ...prev, { kind: "tool" as const, id: `tool-${toolId}-${prev.length}`, tool_id: toolId, name: p?.name ?? "tool", context: p?.context, status: "running" as const, startedAt: Date.now(), }, ].slice(-TOOL_LIMIT), ); } else if (type === "tool.progress") { const p = payload as | { name?: string; preview?: string } | undefined; if (!p?.name || !p.preview) { return; } setTools((prev) => prev.map((t) => t.status === "running" && t.name === p.name ? { ...t, preview: p.preview } : t, ), ); } else if (type === "tool.complete") { const p = payload as | { tool_id?: string; summary?: string; error?: string; inline_diff?: string; } | undefined; if (!p?.tool_id) { return; } setTools((prev) => prev.map((t) => t.tool_id === p.tool_id ? { ...t, status: p.error ? "error" : "done", summary: p.summary, error: p.error, inline_diff: p.inline_diff, completedAt: Date.now(), } : t, ), ); } }); })(); return () => { unmounting = true; ws?.close(); }; }, [channel, version]); const reconnect = useCallback(() => { setError(null); setTools([]); setVersion((v) => v + 1); }, []); const canPickModel = state === "open" && !!sessionId; const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—"; const banner = error ?? info.credential_warning ?? null; return ( ); }