feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher

The dashboard becomes a machine-level management surface with one
write-target selector, replacing per-profile dashboard fragmentation.

Backend:
- profile param (query or body) on /api/config (get/put/raw), /api/env
  (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled),
  /api/mcp/catalog (list/install), /api/model/info, /api/model/set —
  all scoped through the existing _profile_scope() context manager
- model/set restructured: expensive-model warning (await) runs before the
  scope; the config write runs sync inside the scope in a worker thread
- MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>'
- chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the
  profile dir (its own gateway subprocess, config/skills/memory/state.db
  all profile-bound); in-process gateway attach skipped when scoped

CLI launch unification:
- '<profile> dashboard' routes to the machine dashboard: attach (open
  browser at ?profile=) when one is listening, else re-exec pinned to the
  default profile with --open-profile preselecting the launcher
- --isolated preserves the old dedicated per-profile server behavior
- start_server(initial_profile=...) appends ?profile= to the auto-open URL

Frontend:
- ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL-
  persisted (?profile=), mirrored into fetchJSON which auto-appends the
  param to the scoped endpoint families (explicit params win)
- app-wide amber banner names the managed profile
- SkillsPage's page-local selector (from the skills-scoping PR) folded
  into the global context — single source of truth
- ChatPage threads the scope into the PTY WS URL; switching profiles
  remounts the terminal into a fresh scoped session

Omitted profile keeps legacy behavior everywhere.
This commit is contained in:
Teknium
2026-06-10 22:00:06 -07:00
parent 3e74f75e41
commit f02302738d
17 changed files with 1044 additions and 291 deletions
+8
View File
@@ -64,6 +64,9 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { ProfileProvider } from "@/contexts/ProfileProvider";
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
@@ -474,6 +477,7 @@ export default function App() {
}, []);
return (
<ProfileProvider>
<div
data-layout-variant={layoutVariant}
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
@@ -528,6 +532,7 @@ export default function App() {
)}
<PluginSlot name="header-banner" />
<ProfileScopeBanner />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
@@ -602,6 +607,8 @@ export default function App() {
</Button>
</div>
<ProfileSwitcher collapsed={isDesktopCollapsed} />
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
@@ -775,6 +782,7 @@ export default function App() {
<PluginSlot name="overlay" />
</div>
</ProfileProvider>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
/**
* App-wide amber banner shown while the global switcher targets a profile
* OTHER than the dashboard's own — every management write (config, keys,
* skills, MCPs, model) and new Chat sessions land in that profile.
*/
export function ProfileScopeBanner() {
const { profile, currentProfile } = useProfileScope();
const { t } = useI18n();
if (!profile || profile === currentProfile) return null;
return (
<div className="flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.app.managingProfileBanner ??
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
).replace("{name}", profile)}
</span>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* The machine dashboard's single write-target selector.
*
* Rendered in the sidebar above the nav. Every management page (Config,
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
return (
<div
className={cn(
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={t.app.managingProfile ?? "Managing profile"}
>
<Users
className={cn(
"h-3.5 w-3.5 shrink-0",
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<select
aria-label={t.app.managingProfile ?? "Managing profile"}
className={cn(
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
isOther
? "border-amber-500/50 text-amber-300"
: "border-border text-text-secondary",
collapsed && "lg:hidden",
)}
value={profile}
onChange={(e) => setProfile(e.target.value)}
>
<option value="">
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{collapsed && (
<span className="sr-only">{managed}</span>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useSearchParams } from "react-router-dom";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. The selection lives in the URL
* (`?profile=<name>`) so it survives refresh and deep-links, and is mirrored
* into the api module so `fetchJSON` transparently appends it to the
* profile-scoped endpoint families. "" = the dashboard's own profile.
*
* 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.
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
const profile = searchParams.get("profile") ?? "";
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => {});
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});
+6
View File
@@ -0,0 +1,6 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}
+4
View File
@@ -93,6 +93,10 @@ export const en: Translations = {
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
managingProfile: "Managing profile",
currentProfileOption: "this dashboard ({name})",
managingProfileBanner:
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
},
status: {
+4
View File
@@ -110,6 +110,10 @@ export interface Translations {
statusOverview: string;
system: string;
webUi: string;
/** Optional — fall back to English literals until translated. */
managingProfile?: string;
currentProfileOption?: string;
managingProfileBanner?: string;
};
// ── Status page ──
+41
View File
@@ -41,11 +41,52 @@ function setSessionHeader(headers: Headers, token: string): void {
}
}
// ── Global management-profile scope ──────────────────────────────────
// The dashboard is a machine-level management surface: one header switcher
// (ProfileProvider in App.tsx) decides which profile the management pages
// read/write, and fetchJSON transparently appends ?profile=<name> to the
// profile-scoped endpoint families below. "" = the dashboard process's own
// profile (legacy behavior). Calls that already carry an explicit profile
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
let _managementProfile = "";
export function setManagementProfile(name: string): void {
_managementProfile = (name || "").trim();
}
export function getManagementProfile(): string {
return _managementProfile;
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// channels, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/skills",
"/api/tools/toolsets",
"/api/config",
"/api/env",
"/api/mcp",
"/api/model/info",
"/api/model/set",
];
function withManagementProfile(url: string): string {
if (!_managementProfile) return url;
if (url.includes("profile=")) return url; // explicit param wins
const path = url.split("?")[0];
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
}
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
options?: FetchJSONOptions,
): Promise<T> {
url = withManagementProfile(url);
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
+13 -3
View File
@@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
function buildWsUrl(
authParam: [string, string],
resume: string | null,
channel: string,
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
@@ -49,6 +51,10 @@ function buildWsUrl(
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
if (profile) qs.set("profile", profile);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
@@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
const resumeParam = searchParams.get("resume");
const channel = useMemo(() => generateChannelId(), [resumeParam]);
// Profile-scoped chat: spawn the PTY under the globally selected
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
useEffect(() => {
if (!resumeParam) return;
@@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
@@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
copyResetRef.current = null;
}
};
}, [channel, resumeParam]);
}, [channel, resumeParam, scopedProfile]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.
+10 -91
View File
@@ -25,7 +25,6 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Users,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@@ -36,9 +35,8 @@ import type {
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubScan,
ProfileInfo,
} from "@/lib/api";
import { useSearchParams } from "react-router-dom";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
@@ -137,51 +135,15 @@ export default function SkillsPage() {
const { setAfterTitle, setEnd } = usePageHeader();
// ── Profile scoping ──
// The dashboard process runs under ONE profile, but skills/toolsets are
// per-profile state. Without an explicit selector, users who "activated"
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
// runs) toggled skills here and silently wrote into the dashboard's own
// profile. The selector makes the write target explicit and deep-linkable
// via /skills?profile=<name>.
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [currentProfile, setCurrentProfile] = useState<string>("");
const urlProfile = searchParams.get("profile") ?? "";
// "" = the dashboard's own profile (legacy behavior).
const selectedProfile = urlProfile;
const setSelectedProfile = useCallback(
(name: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
// The profile actually being managed, for display purposes.
const managedProfile = selectedProfile || currentProfile || "default";
const managingOtherProfile =
!!selectedProfile && selectedProfile !== currentProfile;
useEffect(() => {
// Profile list + the dashboard's own profile, for the selector. Failure
// leaves the selector hidden — the page still works profile-unscoped.
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => setCurrentProfile("default"));
}, []);
// The write target comes from the GLOBAL profile switcher (sidebar) via
// ProfileContext — one selector for the whole dashboard, deep-linkable
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
// appends the param automatically; we still pass it explicitly where the
// call signature supports it (clearer, and robust if a caller bypasses
// the auto-injection).
const {
profile: selectedProfile,
} = useProfileScope();
useEffect(() => {
// Promise-chain shape: setState fires only inside async callbacks so the
@@ -298,33 +260,6 @@ export default function SkillsPage() {
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
{profiles.length > 1 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
<select
aria-label={t.skills.profileSelector ?? "Profile"}
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
value={selectedProfile}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedProfile(e.target.value)
}
>
<option value="">
{(t.skills.currentProfile ?? "current ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((p) => p.name !== currentProfile)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
</span>
)}
</span>,
);
setEnd(
@@ -361,10 +296,6 @@ export default function SkillsPage() {
setEnd,
skills.length,
t,
profiles,
selectedProfile,
currentProfile,
setSelectedProfile,
]);
const filteredToolsets = useMemo(() => {
@@ -391,18 +322,6 @@ export default function SkillsPage() {
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{managingOtherProfile && (
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.skills.managingProfile ??
"Managing profile “{name}” — toggles apply to that profile, not this dashboards."
).replace("{name}", managedProfile)}
</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">