From cb3d9038a745574d19e1e7ae74b81e4cc9ccc169 Mon Sep 17 00:00:00 2001 From: IAvecilla Date: Wed, 17 Jun 2026 15:58:04 -0300 Subject: [PATCH] Fix model picker and autorefresh on change --- web/src/components/ChatSidebar.tsx | 136 ++++++++++++++++------ web/src/components/ModelReloadConfirm.tsx | 40 +++++++ web/src/pages/ModelsPage.tsx | 21 +++- 3 files changed, 160 insertions(+), 37 deletions(-) create mode 100644 web/src/components/ModelReloadConfirm.tsx diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index e6e3437781a..16b99938d8e 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -4,12 +4,13 @@ * * 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.). + * 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 @@ -28,9 +29,10 @@ 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 { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; -import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; +import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; @@ -92,11 +94,37 @@ export function ChatSidebar({ 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); + // 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(""); + // 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)); + }) + .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 @@ -120,17 +148,12 @@ export function ChatSidebar({ let cancelled = false; queueMicrotask(() => { if (cancelled) return; - 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 })); } @@ -144,9 +167,10 @@ export function ChatSidebar({ } }); - // 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. + // 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) { @@ -159,12 +183,6 @@ export function ChatSidebar({ ...(profile ? { profile } : {}), }); }) - .then((created) => { - if (cancelled || !created?.session_id) { - return; - } - setSessionId(created.session_id); - }) .catch((e: Error) => { if (!cancelled) { setError(e.message); @@ -322,14 +340,24 @@ export function ChatSidebar({ }; }, [channel, onDashboardNewSessionRequest, 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); setTools([]); + setModelNotice(null); + setPendingReloadModel(null); setVersion((v) => v + 1); }, []); - const canPickModel = state === "open" && !!sessionId; - const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—"; + // 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 ( @@ -348,21 +376,18 @@ export function ChatSidebar({ @@ -372,6 +397,16 @@ export function ChatSidebar({ + {modelNotice && ( + + + +
+ {modelNotice} +
+
+ )} + {banner && ( @@ -410,13 +445,48 @@ export function ChatSidebar({ - {modelOpen && canPickModel && sessionId && ( + {modelOpen && ( setModelOpen(false)} + // Same path the Models page uses (REST /api/model/set), not the + // sidecar config.set RPC, which didn't reliably land in the + // config.yaml the agent boots from. Always persisted (alwaysGlobal). + loader={api.getModelOptions} + alwaysGlobal + onApply={async ({ provider, model, confirmExpensiveModel }) => { + setModelNotice(null); + setPendingReloadModel(null); + const result = await api.setModelAssignment({ + confirm_expensive_model: confirmExpensiveModel, + scope: "main", + provider, + model, + }); + // confirm_required => the dialog shows the expensive-model prompt + // and calls back; don't announce until the user confirms. + if (!result.confirm_required) { + refreshEffectiveModel(); + // Ask before reloading: applying the model starts a fresh chat. + setPendingReloadModel(model.split("/").slice(-1)[0]); + } + return result; + }} + onClose={() => { + setModelOpen(false); + refreshEffectiveModel(); + }} /> )} + + { + const m = pendingReloadModel; + setPendingReloadModel(null); + setModelNotice( + `Model set to ${m}. Run /new or refresh the page to apply it to this chat.`, + ); + }} + /> ); } diff --git a/web/src/components/ModelReloadConfirm.tsx b/web/src/components/ModelReloadConfirm.tsx new file mode 100644 index 00000000000..3b5d27d615b --- /dev/null +++ b/web/src/components/ModelReloadConfirm.tsx @@ -0,0 +1,40 @@ +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +/** + * Confirm + full-page reload after a model change. + * + * Changing the main model persists to config.yaml, but the RUNNING chat keeps + * its model until its session is rebuilt. A full reload (fresh PTY session that + * boots its agent from the just-saved config) is the reliable way to apply it — + * the in-place hot-swap and partial remount both proved unreliable. We confirm + * first because the reload starts a fresh chat (the current one stays resumable + * in Sessions and the agent's memory is kept). + * + * Shared by the chat sidebar picker and the Models page so both behave + * identically. `model` is the short model name awaiting confirmation, or null + * when the dialog is closed. + */ +export function ModelReloadConfirm({ + model, + description, + onCancel, +}: { + model: string | null; + /** Override the default body copy (e.g. the Models-page phrasing). */ + description?: string; + onCancel: () => void; +}) { + return ( + window.location.reload()} + onCancel={onCancel} + /> + ); +} diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index 77953412b6f..0580feca4e1 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -32,6 +32,7 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; +import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; const PERIODS = [ { label: "7d", days: 7 }, @@ -697,6 +698,9 @@ function ModelSettingsPanel({ }) { const [auxModalOpen, setAuxModalOpen] = useState(false); const [picker, setPicker] = useState(null); + const [pendingReloadModel, setPendingReloadModel] = useState( + null, + ); const mainProv = aux?.main.provider ?? ""; const mainModel = aux?.main.model ?? ""; @@ -798,15 +802,19 @@ function ModelSettingsPanel({ loader={api.getModelOptions} alwaysGlobal title="Set Main Model" - onApply={({ provider, model, confirmExpensiveModel }) => - applyAssignment({ + onApply={async ({ provider, model, confirmExpensiveModel }) => { + const result = await applyAssignment({ confirmExpensiveModel, scope: "main", task: "", provider, model, - }) - } + }); + if (!result.confirm_required) { + setPendingReloadModel(model.split("/").slice(-1)[0]); + } + return result; + }} onClose={() => setPicker(null)} /> )} @@ -819,6 +827,11 @@ function ModelSettingsPanel({ onClose={() => setAuxModalOpen(false)} /> )} + + setPendingReloadModel(null)} + /> );