From 9954781d30fb568520204cd581878281b8398258 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:06:59 +0400 Subject: [PATCH 1/5] fix(studio/chat): cancel in-flight run when trashing a thread from sidebar (#5067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trashing a thread mid-stream used to delete the Dexie rows while the model kept generating, because the sidebar has no access to the @assistant-ui aui context. Expose per-thread cancelRun() through the chat runtime store and call it from deleteChatItem so trash behaves like Stop → Trash. Covers compare pairs by cancelling each paired thread. Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- .../chat/hooks/use-chat-sidebar-items.ts | 31 +++++++++++++------ .../src/features/chat/runtime-provider.tsx | 29 +++++++++++++++++ .../chat/stores/chat-runtime-store.ts | 17 ++++++++++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts index fb27f990cf..33cdd88081 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts @@ -58,23 +58,36 @@ export function useChatSidebarItems() { return { items, canCompare }; } +function cancelIfRunning(threadId: string): void { + const { runningByThreadId, cancelByThreadId } = + useChatRuntimeStore.getState(); + if (!runningByThreadId[threadId]) return; + cancelByThreadId[threadId]?.(); +} + export async function deleteChatItem( item: SidebarItem, activeId: string | undefined, onSelect: (view: { mode: "single"; newThreadNonce: string }) => void, ) { + const threadIds: string[] = + item.type === "single" + ? [item.id] + : (await db.threads.where("pairId").equals(item.id).toArray()).map( + (t) => t.id, + ); + + // Stop any in-flight streams before deleting, so the model doesn't keep + // generating against a thread that no longer exists. + for (const id of threadIds) cancelIfRunning(id); + await db.transaction("rw", db.threads, db.messages, async () => { - if (item.type === "single") { - await db.messages.where("threadId").equals(item.id).delete(); - await db.threads.delete(item.id); - } else { - const paired = await db.threads.where("pairId").equals(item.id).toArray(); - for (const t of paired) { - await db.messages.where("threadId").equals(t.id).delete(); - await db.threads.delete(t.id); - } + for (const id of threadIds) { + await db.messages.where("threadId").equals(id).delete(); + await db.threads.delete(id); } }); + if (activeId === item.id) { useChatRuntimeStore.getState().setActiveThreadId(null); onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() }); diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 348a0a85c4..5747e17970 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -730,6 +730,34 @@ function ActiveThreadSync({ return null; } +// Exposes the current thread's cancelRun() via the shared store so external +// surfaces (e.g. the sidebar trash button) can stop an in-flight stream +// before deleting the thread — mirroring the Stop → Trash sequence. +function CancelRegistrar(): ReactElement | null { + const aui = useAui(); + const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId); + const isRunning = useChatRuntimeStore((s) => + mainThreadId ? Boolean(s.runningByThreadId[mainThreadId]) : false, + ); + + useEffect(() => { + if (!mainThreadId || !isRunning) return; + const cancel = () => { + try { + aui.thread().cancelRun(); + } catch { + // Run may have already ended between the caller's read and this call. + } + }; + useChatRuntimeStore.getState().registerThreadCancel(mainThreadId, cancel); + return () => { + useChatRuntimeStore.getState().clearThreadCancel(mainThreadId); + }; + }, [aui, mainThreadId, isRunning]); + + return null; +} + export function ChatRuntimeProvider({ children, modelType = "base", @@ -762,6 +790,7 @@ export function ChatRuntimeProvider({ + {initialThreadId && ( ; + cancelByThreadId: Record void>; autoTitle: boolean; hfToken: string; modelsError: string | null; @@ -189,6 +190,8 @@ type ChatRuntimeStore = { setModels: (models: ChatModelSummary[]) => void; setLoras: (loras: ChatLoraSummary[]) => void; setThreadRunning: (threadId: string, running: boolean) => void; + registerThreadCancel: (threadId: string, cancel: () => void) => void; + clearThreadCancel: (threadId: string) => void; setAutoTitle: (enabled: boolean) => void; setHfToken: (token: string) => void; setModelsError: (error: string | null) => void; @@ -218,6 +221,7 @@ export const useChatRuntimeStore = create((set) => ({ models: [], loras: [], runningByThreadId: {}, + cancelByThreadId: {}, autoTitle: loadBool(AUTO_TITLE_KEY, false), hfToken: loadString(HF_TOKEN_KEY, ""), modelsError: null, @@ -277,6 +281,19 @@ export const useChatRuntimeStore = create((set) => ({ } return { runningByThreadId: next }; }), + registerThreadCancel: (threadId, cancel) => + set((state) => { + const next = { ...state.cancelByThreadId }; + next[threadId] = cancel; + return { cancelByThreadId: next }; + }), + clearThreadCancel: (threadId) => + set((state) => { + if (!(threadId in state.cancelByThreadId)) return state; + const next = { ...state.cancelByThreadId }; + delete next[threadId]; + return { cancelByThreadId: next }; + }), setAutoTitle: (autoTitle) => set(() => { saveBool(AUTO_TITLE_KEY, autoTitle); From 9c8a079d97bb20ac62128321fa8b889ab6dcf2c7 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:28:02 +0100 Subject: [PATCH 2/5] 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 --- .../frontend/src/components/app-sidebar.tsx | 22 ++- studio/frontend/src/features/auth/index.ts | 1 + .../profile-personalization-panel.tsx | 157 ++++++++++++++++++ .../profile/components/user-avatar.tsx | 45 +++++ .../profile/hooks/use-effective-profile.ts | 19 +++ studio/frontend/src/features/profile/index.ts | 6 + .../profile/stores/user-profile-store.ts | 24 +++ .../features/profile/utils/avatar-initials.ts | 13 ++ .../src/features/profile/utils/jwt-subject.ts | 21 +++ .../profile/utils/resize-image-file.ts | 53 ++++++ .../src/features/settings/settings-dialog.tsx | 7 +- .../settings/stores/settings-dialog-store.ts | 3 +- .../features/settings/tabs/general-tab.tsx | 2 + .../features/settings/tabs/profile-tab.tsx | 19 +++ 14 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 studio/frontend/src/features/profile/components/profile-personalization-panel.tsx create mode 100644 studio/frontend/src/features/profile/components/user-avatar.tsx create mode 100644 studio/frontend/src/features/profile/hooks/use-effective-profile.ts create mode 100644 studio/frontend/src/features/profile/index.ts create mode 100644 studio/frontend/src/features/profile/stores/user-profile-store.ts create mode 100644 studio/frontend/src/features/profile/utils/avatar-initials.ts create mode 100644 studio/frontend/src/features/profile/utils/jwt-subject.ts create mode 100644 studio/frontend/src/features/profile/utils/resize-image-file.ts create mode 100644 studio/frontend/src/features/settings/tabs/profile-tab.tsx diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 329175fa9c..032244ac39 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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() { )} - + - Unsloth +
+ +
- Unsloth + {displayTitle} Train
diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 75db92432c..af629cfb9a 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -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, diff --git a/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx new file mode 100644 index 0000000000..994115bc19 --- /dev/null +++ b/studio/frontend/src/features/profile/components/profile-personalization-panel.tsx @@ -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(null); + const [draftName, setDraftName] = useState(displayName); + const fileInputRef = useRef(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 ( +
+
+ + { + void onPickFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> + +
+ +
+ +
+ 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" + /> + +
+
+ + {imageError ? ( +

+ {imageError} +

+ ) : null} +
+ ); +} diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx new file mode 100644 index 0000000000..62e37f5133 --- /dev/null +++ b/studio/frontend/src/features/profile/components/user-avatar.tsx @@ -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 ( + + + + ); + } + + return ( + + {label} + + ); +} diff --git a/studio/frontend/src/features/profile/hooks/use-effective-profile.ts b/studio/frontend/src/features/profile/hooks/use-effective-profile.ts new file mode 100644 index 0000000000..3b64519e92 --- /dev/null +++ b/studio/frontend/src/features/profile/hooks/use-effective-profile.ts @@ -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, + }; +} diff --git a/studio/frontend/src/features/profile/index.ts b/studio/frontend/src/features/profile/index.ts new file mode 100644 index 0000000000..feec20607e --- /dev/null +++ b/studio/frontend/src/features/profile/index.ts @@ -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"; diff --git a/studio/frontend/src/features/profile/stores/user-profile-store.ts b/studio/frontend/src/features/profile/stores/user-profile-store.ts new file mode 100644 index 0000000000..5bbb4d11c9 --- /dev/null +++ b/studio/frontend/src/features/profile/stores/user-profile-store.ts @@ -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()( + persist( + (set) => ({ + displayName: "", + avatarDataUrl: null, + setDisplayName: (displayName) => set({ displayName }), + setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }), + }), + { name: "unsloth_user_profile" }, + ), +); diff --git a/studio/frontend/src/features/profile/utils/avatar-initials.ts b/studio/frontend/src/features/profile/utils/avatar-initials.ts new file mode 100644 index 0000000000..926f57f3b5 --- /dev/null +++ b/studio/frontend/src/features/profile/utils/avatar-initials.ts @@ -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%)" }; +} diff --git a/studio/frontend/src/features/profile/utils/jwt-subject.ts b/studio/frontend/src/features/profile/utils/jwt-subject.ts new file mode 100644 index 0000000000..9c7596966a --- /dev/null +++ b/studio/frontend/src/features/profile/utils/jwt-subject.ts @@ -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; + } +} diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts new file mode 100644 index 0000000000..3f829ba975 --- /dev/null +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -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 { + 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 { + 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; +} diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index 0e2083b31a..cdccf2fc72 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -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 ; + case "profile": + return ; case "appearance": return ; case "chat": @@ -131,7 +136,7 @@ export function SettingsDialog() { > -
+
{renderTab(activeTab)}
diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts index 75eb53048e..d1fd4a1d0f 100644 --- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts +++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts @@ -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"; } diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 874508a6fb..6081e90605 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -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", ]; diff --git a/studio/frontend/src/features/settings/tabs/profile-tab.tsx b/studio/frontend/src/features/settings/tabs/profile-tab.tsx new file mode 100644 index 0000000000..2ae283b767 --- /dev/null +++ b/studio/frontend/src/features/settings/tabs/profile-tab.tsx @@ -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 ( +
+
+

Profile

+

+ Update how your profile appears in Studio. +

+
+ + +
+ ); +} From 5814e4534589558598eb64de87711e331dcfd3bb Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Mon, 20 Apr 2026 19:44:51 +0100 Subject: [PATCH 3/5] Fix: textarea overflow in system prompt editor --- studio/frontend/src/components/ui/textarea.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/studio/frontend/src/components/ui/textarea.tsx b/studio/frontend/src/components/ui/textarea.tsx index b71e593958..36d86e28be 100644 --- a/studio/frontend/src/components/ui/textarea.tsx +++ b/studio/frontend/src/components/ui/textarea.tsx @@ -8,12 +8,12 @@ import { cn } from "@/lib/utils"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return (