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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
export function useProfileScope() {
|
||||
return useContext(ProfileContext);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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__;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 dashboard’s."
|
||||
).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">
|
||||
|
||||
Reference in New Issue
Block a user