Studio: Local profile customization in settings and sync sidebar identity (#5088)

* studio: add local profile customization in settings

* studio: add local profile settings and sync sidebar identity

* fix: adjust profile card margin

* fix: move helper modules to utils and use single-letter avatar fallback

* fix: keep profile icon visible on sidebar collapse

* fix: sidebar account trigger labeling and profile reset prefs
This commit is contained in:
Lee Jackson 2026-04-20 19:28:02 +01:00 committed by GitHub
parent 9954781d30
commit 9c8a079d97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 382 additions and 10 deletions

View file

@ -55,6 +55,7 @@ import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useSettingsDialogStore } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { usePlatformStore } from "@/config/env";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import {
@ -185,6 +186,7 @@ export function AppSidebar() {
const effectiveRunsOpen = isStudioRoute || runsOpen;
const isRecipesRoute = pathname.startsWith("/data-recipes");
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
const { items: chatItems } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
@ -495,22 +497,26 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border">
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={`${displayTitle} account menu`}
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:overflow-visible group-data-[collapsible=icon]:hover:bg-transparent group-data-[collapsible=icon]:data-[state=open]:bg-transparent"
>
<img
src="/Sloth emojis/sloth rounded.png"
alt="Unsloth"
className="size-8 rounded-lg shrink-0"
/>
<div className="shrink-0">
<UserAvatar
name={displayTitle}
imageUrl={avatarDataUrl}
size="sm"
className="!size-8"
/>
</div>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">Unsloth</span>
<span className="truncate text-sm font-semibold">{displayTitle}</span>
<span className="truncate text-[11px] text-muted-foreground">Train</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />

View file

@ -5,6 +5,7 @@ export { LoginPage } from "./login-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";
export {
getAuthToken,
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,

View file

@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthToken } from "@/features/auth";
import { toastError, toastSuccess } from "@/shared/toast";
import { Camera } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
import { useUserProfileStore } from "../stores/user-profile-store";
import { UserAvatar } from "./user-avatar";
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null {
try {
const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
// Zustand persist shape: { state: {...}, version }
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
if (!maybeState || typeof maybeState !== "object") return null;
const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown };
return {
displayName: typeof state.displayName === "string" ? state.displayName : "",
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
};
} catch {
return null;
}
}
export function ProfilePersonalizationPanel() {
const displayName = useUserProfileStore((s) => s.displayName);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
const fileInputRef = useRef<HTMLInputElement>(null);
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
const previewName = draftName.trim() || sessionSub || "Unsloth";
const hasNameChanges = useMemo(
() => draftName.trim() !== displayName.trim(),
[draftName, displayName],
);
const saveName = () => {
const trimmed = draftName.trim();
if (trimmed !== draftName) setDraftName(trimmed);
if (trimmed !== displayName) {
setDisplayName(trimmed);
const persisted = readPersistedProfile();
if (persisted && persisted.displayName === trimmed) {
toastSuccess("Profile name saved");
} else {
toastError(
"Could not persist profile name",
"Name updated for this session, but may not persist after reload.",
);
}
}
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
setImageError(null);
try {
const dataUrl = await resizeImageFileToDataUrl(file);
setAvatarDataUrl(dataUrl);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === dataUrl) {
toastSuccess("Profile photo updated");
} else {
toastError(
"Could not persist profile photo",
"Photo updated for this session, but may not persist after reload.",
);
}
} catch (e) {
const message = e instanceof Error ? e.message : "Could not use this image.";
setImageError(message);
toastError("Could not update profile photo", message);
}
};
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
<div className="relative">
<UserAvatar
name={previewName}
imageUrl={avatarDataUrl}
size="lg"
className="size-[124px] text-[3.15rem]"
/>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
void onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Change profile picture"
>
<Camera className="size-3.5" strokeWidth={2} />
</button>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
Display name
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-display-name"
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
}
}}
autoComplete="off"
placeholder={sessionSub || "Unsloth"}
className="h-10 min-w-0 flex-1 rounded-lg text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
Save
</Button>
</div>
</div>
{imageError ? (
<p className="w-full text-xs text-destructive" role="alert">
{imageError}
</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials";
type UserAvatarProps = {
name: string;
imageUrl: string | null;
size: "sm" | "md" | "lg";
className?: string;
};
const SIZE: Record<"sm" | "md" | "lg", string> = {
sm: "size-9 text-xs",
md: "size-11 text-sm",
/** ~10% larger than `size-24` / `text-2xl` for the edit-profile dialog. */
lg: "size-[106px] text-[1.65rem]",
};
export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) {
const label = initialsFromName(name);
if (imageUrl) {
return (
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);
}
return (
<span
style={avatarBgStyle()}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
SIZE[size],
className,
)}
aria-hidden
>
{label}
</span>
);
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { useUserProfileStore } from "../stores/user-profile-store";
export function useEffectiveProfile() {
const displayName = useUserProfileStore((s) => s.displayName);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const sessionSub = decodeJwtSubject(getAuthToken());
const dn = displayName.trim();
return {
sessionSub,
displayTitle: dn || "Unsloth",
avatarDataUrl,
};
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ProfilePersonalizationPanel } from "./components/profile-personalization-panel";
export { UserAvatar } from "./components/user-avatar";
export { useEffectiveProfile } from "./hooks/use-effective-profile";

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface UserProfileState {
displayName: string;
avatarDataUrl: string | null;
setDisplayName: (displayName: string) => void;
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
}
export const useUserProfileStore = create<UserProfileState>()(
persist(
(set) => ({
displayName: "",
avatarDataUrl: null,
setDisplayName: (displayName) => set({ displayName }),
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
}),
{ name: "unsloth_user_profile" },
),
);

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function initialsFromName(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "?";
return trimmed[0]!.toUpperCase();
}
/** Default blue background for avatar fallback (readable white text). */
export function avatarBgStyle(): { backgroundColor: string } {
return { backgroundColor: "hsl(217 58% 48%)" };
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Read the JWT `sub` claim for display purposes only (not verified).
*/
export function decodeJwtSubject(token: string | null): string | null {
if (!token) return null;
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = parts[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const json = atob(padded);
const parsed = JSON.parse(json) as { sub?: unknown };
return typeof parsed.sub === "string" ? parsed.sub : null;
} catch {
return null;
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const MAX_EDGE = 256;
const MAX_BYTES = 380_000;
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not load image"));
};
img.src = url;
});
}
/**
* Downscale and re-encode as JPEG so localStorage stays within reasonable size.
*/
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
const img = await loadImage(file);
const w = img.naturalWidth;
const h = img.naturalHeight;
if (!w || !h) throw new Error("Invalid image dimensions");
const scale = Math.min(1, MAX_EDGE / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not available");
ctx.drawImage(img, 0, 0, cw, ch);
let quality = 0.88;
let dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > MAX_BYTES * 1.35 && quality > 0.45) {
quality -= 0.08;
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
if (dataUrl.length > MAX_BYTES * 1.35) {
throw new Error("Image is still too large after compression. Try a smaller file.");
}
return dataUrl;
}

View file

@ -15,6 +15,7 @@ import {
PaintBrush02Icon,
Settings02Icon,
SparklesIcon,
UserIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
@ -24,6 +25,7 @@ import { ApiKeysTab } from "./tabs/api-keys-tab";
import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
interface TabDef {
id: SettingsTab;
@ -33,6 +35,7 @@ interface TabDef {
const TABS: TabDef[] = [
{ id: "general", label: "General", icon: Settings02Icon },
{ id: "profile", label: "Profile", icon: UserIcon },
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
{ id: "chat", label: "Chat", icon: Message01Icon },
{ id: "api-keys", label: "API Keys", icon: Key01Icon },
@ -43,6 +46,8 @@ function renderTab(tab: SettingsTab) {
switch (tab) {
case "general":
return <GeneralTab />;
case "profile":
return <ProfileTab />;
case "appearance":
return <AppearanceTab />;
case "chat":
@ -131,7 +136,7 @@ export function SettingsDialog() {
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 pr-12">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
{renderTab(activeTab)}
</div>
</main>

View file

@ -5,6 +5,7 @@ import { create } from "zustand";
export type SettingsTab =
| "general"
| "profile"
| "appearance"
| "chat"
| "api-keys"
@ -28,7 +29,7 @@ function loadInitialTab(): SettingsTab {
} catch {
return "general";
}
const valid: SettingsTab[] = ["general", "appearance", "chat", "api-keys", "about"];
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
}

View file

@ -56,6 +56,8 @@ const PREFS_KEYS: string[] = [
"unsloth_training_config_v1",
"unsloth_prev_max_steps",
"unsloth_prev_save_steps",
// Profile personalization
"unsloth_user_profile",
// Guided tour flags
"tour:studio:v1",
];

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { ProfilePersonalizationPanel } from "@/features/profile";
export function ProfileTab() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">Profile</h1>
<p className="text-xs text-muted-foreground">
Update how your profile appears in Studio.
</p>
</header>
<ProfilePersonalizationPanel />
</div>
);
}