From 0bbf325a8f50cbc7cfc74886705e8a67bb521dad Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 12:50:19 -0400 Subject: [PATCH] fix(dashboard): scope chat sidebar model card to selected profile (#46665) * 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 * 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 * 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 * 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 --------- Co-authored-by: Cursor --- ...t_dashboard_sidecar_close_on_disconnect.py | 12 +++++ web/src/components/ChatSidebar.tsx | 54 +++++++++++++++---- web/src/contexts/ProfileProvider.tsx | 44 +++++++++++---- web/src/pages/ChatPage.tsx | 4 +- web/src/pages/ProfilesPage.tsx | 9 ++-- 5 files changed, 94 insertions(+), 29 deletions(-) diff --git a/tests/test_dashboard_sidecar_close_on_disconnect.py b/tests/test_dashboard_sidecar_close_on_disconnect.py index bb11e688cf1..b3490900d4f 100644 --- a/tests/test_dashboard_sidecar_close_on_disconnect.py +++ b/tests/test_dashboard_sidecar_close_on_disconnect.py @@ -11,3 +11,15 @@ def test_sidecar_session_create_requests_close_on_disconnect(): call = re.search(r'"session\.create",\s*\{(.*?)\}', source, re.DOTALL) assert call, "sidecar session.create call not found" assert re.search(r"close_on_disconnect:\s*true", call.group(1)) + + +def test_sidecar_session_create_scopes_profile(): + """The sidecar must pass the dashboard's selected profile so model/credential + info matches the PTY child under profile-scoped chat.""" + source = CHAT_SIDEBAR.read_text(encoding="utf-8") + assert '"session.create"' in source + assert re.search( + r"close_on_disconnect:\s*true,\s*\.\.\.\(profile\s*\?\s*\{\s*profile\s*\}\s*:\s*\{\}\)", + source, + re.DOTALL, + ) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 66b15b95f92..1a53741d8fd 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -34,7 +34,7 @@ import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface SessionInfo { cwd?: string; @@ -71,10 +71,12 @@ const STATE_TONE: Record< interface ChatSidebarProps { channel: string; + /** Management profile from the dashboard switcher — scopes session.create. */ + profile?: string; className?: string; } -export function ChatSidebar({ channel, className }: ChatSidebarProps) { +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, @@ -90,8 +92,29 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { 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) => { @@ -124,6 +147,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { // 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) => { @@ -145,6 +169,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { offError(); gw.close(); }; + // `profile` is read from render; scope changes bump `version` → new `gw`. }, [gw]); // Event subscriber WebSocket — receives the rebroadcast of every @@ -304,7 +329,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { )} > -
+
model
@@ -314,19 +339,26 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { size="sm" disabled={!canPickModel} onClick={() => setModelOpen(true)} - suffix={ - canPickModel ? ( - - ) : undefined - } - className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline" + 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"} > - {modelLabel} + + {modelLabel} + + {canPickModel ? ( + + ) : null} +
- {STATE_LABEL[state]} + + {STATE_LABEL[state]} + {banner && ( diff --git a/web/src/contexts/ProfileProvider.tsx b/web/src/contexts/ProfileProvider.tsx index 0beedb49bc5..91c5440e413 100644 --- a/web/src/contexts/ProfileProvider.tsx +++ b/web/src/contexts/ProfileProvider.tsx @@ -26,10 +26,12 @@ import { ProfileContext } from "@/contexts/profile-context"; * truth, the effect below re-asserts `?profile=` onto the new location * after each navigation, so the scope survives nav and stays deep-linkable. * - * This exists because "Set as active" on the Profiles page only flips the - * sticky active_profile file (future CLI/gateway runs) — it cannot retarget - * the running dashboard. The switcher is the dashboard's own, visible, - * write-target selector. + * This exists because "Set as active" on the Profiles page historically only + * flipped the sticky active_profile file (future CLI/gateway runs). The + * switcher is the dashboard's write-target selector for Chat and management + * pages. We now sync the switcher when the sticky active profile differs from + * the dashboard process on load, and ProfilesPage updates the switcher when + * you click "Set as active". */ export function ProfileProvider({ children }: { children: ReactNode }) { const [searchParams, setSearchParams] = useSearchParams(); @@ -77,14 +79,34 @@ export function ProfileProvider({ children }: { children: ReactNode }) { }, [pathname, profile]); useEffect(() => { - api - .getProfiles() - .then((res) => setProfiles(res.profiles.map((p) => p.name))) - .catch(() => {}); - api - .getActiveProfile() - .then((info) => setCurrentProfile(info.current || "default")) + let cancelled = false; + const urlProfile = searchParams.get("profile"); + + Promise.all([api.getProfiles(), api.getActiveProfile()]) + .then(([profilesRes, info]) => { + if (cancelled) return; + + setProfiles(profilesRes.profiles.map((p) => p.name)); + + const current = info.current || "default"; + const active = info.active || "default"; + setCurrentProfile(current); + + // Deep links (?profile=) win. Otherwise align the switcher with the + // sticky active profile so Chat and management pages match what the + // Profiles page shows as "active" (machine dashboard runs as + // `current`, usually default). + if (urlProfile === null && active !== current) { + setManagementProfile(active); + setProfileState(active); + } + }) .catch(() => {}); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const setProfile = useCallback( diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 34975035530..b8c1ecbcbf1 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -861,7 +861,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { "border-t border-current/10", )} > - +
, @@ -929,7 +929,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80" >
- +
)} diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index fdf89fa4a41..781ca0c7783 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { useNavigate } from "react-router-dom"; +import { useProfileScope } from "@/contexts/useProfileScope"; import { AlignLeft, Check, @@ -259,6 +260,7 @@ export default function ProfilesPage() { const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); + const { setProfile } = useProfileScope(); // Locale strings with English fallbacks. The enriched keys are optional in // the i18n type so untranslated locales don't break the build — they render @@ -305,7 +307,7 @@ export default function ProfilesPage() { manageSkills: p.manageSkills ?? "Manage skills & tools", activeSetHint: p.activeSetHint ?? - "Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.", + "Dashboard switched to manage {name}. New CLI/gateway runs will use this profile too.", }; }, [t.profiles]); @@ -495,10 +497,7 @@ export default function ProfilesPage() { // The backend normalizes/validates the name; trust the canonical // value it returns rather than the raw input. const { active } = await api.setActiveProfile(name); - // "Set as active" only flips the sticky default for FUTURE CLI/gateway - // invocations — it does NOT retarget this running dashboard. Say so, - // or users assume skill/tool toggles now apply to the activated - // profile (they don't — that's what "Manage skills & tools" is for). + setProfile(active); showToast( `${L.activeSet}: ${active} — ${L.activeSetHint.replace("{name}", active)}`, "success",