/** * 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 "@/components/ui/card"; 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"; 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; className?: string; } 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, // 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); useEffect(() => { let cancelled = false; 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; } return gw.request<{ session_id: string }>("session.create", {}); }) .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(); }; }, [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(() => { const token = window.__HERMES_SESSION_TOKEN__; if (!token || !channel) { return; } const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams({ token, channel }); const ws = new WebSocket( `${proto}//${window.location.host}/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"; let unmounting = false; 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); }, []); // Picker hands us a fully-formed slash command (e.g. "/model anthropic/..."). // Fire-and-forget through `slash.exec`; the TUI pane will render the result // via PTY, so the sidebar doesn't need to surface output of its own. const onModelSubmit = useCallback( (slashCommand: string) => { if (!sessionId) { return; } void gw.request("slash.exec", { session_id: sessionId, command: slashCommand, }); setModelOpen(false); }, [gw, sessionId], ); const canPickModel = state === "open" && !!sessionId; const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—"; const banner = error ?? info.credential_warning ?? null; return ( ); }