mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
* fix(dashboard): scope chat sidebar model card to selected profile The PTY already honors ?profile= on profile switch, but the JSON-RPC sidecar created sessions against the dashboard launch profile. Pass the management profile through session.create and reconnect on switch. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dashboard): sync active profile with management scope Align the sidebar switcher with the sticky active profile on load and when "Set as active" is clicked, so Chat and management pages match what the Profiles page shows as active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dashboard): auto-reconnect chat sidebar on profile switch Bump the sidecar connection version when profile or PTY channel changes, matching the manual Reconnect path so gateway and events sockets come back without clicking the error banner. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dashboard): prevent model selector chevron overlapping label Use inline flex layout instead of Button suffix, which is absolutely positioned and overlapped truncated model names at px-0. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
/**
|
|
* 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<ConnectionState, string> = {
|
|
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<ConnectionState>("idle");
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [info, setInfo] = useState<SessionInfo>({});
|
|
const [tools, setTools] = useState<ToolEntry[]>([]);
|
|
const [modelOpen, setModelOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<string | null>(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<SessionInfo>("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=<session> 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 (
|
|
<aside
|
|
className={cn(
|
|
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80",
|
|
className,
|
|
)}
|
|
>
|
|
<Card className="flex items-center justify-between gap-2 px-3 py-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-display text-xs tracking-wider text-text-tertiary">
|
|
model
|
|
</div>
|
|
|
|
<Button
|
|
ghost
|
|
size="sm"
|
|
disabled={!canPickModel}
|
|
onClick={() => setModelOpen(true)}
|
|
className={cn(
|
|
"max-w-full min-w-0 px-0 py-0",
|
|
"self-start normal-case tracking-normal text-sm font-medium",
|
|
"hover:underline disabled:no-underline",
|
|
)}
|
|
title={info.model ?? "switch model"}
|
|
>
|
|
<span className="flex min-w-0 max-w-full items-center gap-1">
|
|
<span className="truncate">{modelLabel}</span>
|
|
|
|
{canPickModel ? (
|
|
<ChevronDown className="size-3.5 shrink-0 text-text-secondary" />
|
|
) : null}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<Badge tone={STATE_TONE[state]} className="shrink-0">
|
|
{STATE_LABEL[state]}
|
|
</Badge>
|
|
</Card>
|
|
|
|
{banner && (
|
|
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="wrap-break-word text-destructive">{banner}</div>
|
|
|
|
{error && (
|
|
<Button
|
|
size="sm"
|
|
outlined
|
|
className="mt-1"
|
|
onClick={reconnect}
|
|
prefix={<RefreshCw />}
|
|
>
|
|
reconnect
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="flex min-h-0 flex-none flex-col px-2 py-2">
|
|
<div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary">
|
|
tools
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-col gap-1.5">
|
|
{tools.length === 0 ? (
|
|
<div className="px-2 py-4 text-center text-xs text-text-secondary">
|
|
no tool calls yet
|
|
</div>
|
|
) : (
|
|
tools.map((t) => <ToolCall key={t.id} tool={t} />)
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{modelOpen && canPickModel && sessionId && (
|
|
<ModelPickerDialog
|
|
gw={gw}
|
|
sessionId={sessionId}
|
|
onClose={() => setModelOpen(false)}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|