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",