| null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (dismissTimerRef.current) {
+ clearTimeout(dismissTimerRef.current);
+ }
+ };
+ }, []);
+
+ if (isTauri) {
+ return null;
+ }
+
+ async function handleCopyCommand() {
+ if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
+ return;
+ }
+ setCopiedVersion(status?.latestVersion ?? null);
+ if (dismissTimerRef.current) {
+ clearTimeout(dismissTimerRef.current);
+ }
+ dismissTimerRef.current = setTimeout(() => dismiss(), 900);
+ }
+
+ return (
+
+ {status ? (
+
+
+
+
+
+
+ đŸ¦¥
+
+
+
+ Package update available: {status.latestVersion}
+
+
+ Installed package: {status.currentVersion}. To update Studio,
+ run this in your terminal, then restart Studio.
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx
index 4d0b87981b..e4cdccd2d7 100644
--- a/studio/frontend/src/features/settings/components/update-studio-instructions.tsx
+++ b/studio/frontend/src/features/settings/components/update-studio-instructions.tsx
@@ -1,8 +1,8 @@
// 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 { copyToClipboard } from "@/lib/copy-to-clipboard";
+import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@@ -14,11 +14,42 @@ const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
+const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
+const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
+const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
+const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
export type UpdateShell = "windows" | "unix";
+export type UpdateInstallSource =
+ | "pypi"
+ | "editable"
+ | "local_path"
+ | "vcs"
+ | "local_repo"
+ | "unknown";
+type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
- return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
+ return shell === "windows"
+ ? "Open PowerShell and run:"
+ : "Open Terminal and run:";
+}
+
+function isLocalInstallSource(
+ installSource?: UpdateInstallSourceState | null,
+): boolean {
+ return Boolean(
+ installSource &&
+ installSource !== "pypi" &&
+ installSource !== "unknown" &&
+ installSource !== "loading",
+ );
+}
+
+function isUnknownInstallSource(
+ installSource?: UpdateInstallSourceState | null,
+): boolean {
+ return installSource === "unknown";
}
function CopyableCommand({
@@ -54,7 +85,7 @@ function CopyableCommand({
{copied ? (
-
+
) : (
)}
@@ -77,24 +111,38 @@ function CopyableCommand({
);
}
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
defaultShell,
+ installSource,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
+ installSource?: UpdateInstallSourceState | null;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
+ const localInstallSource = isLocalInstallSource(installSource);
+ const checkoutInstallSource =
+ installSource === "editable" || installSource === "local_repo";
+ const packagedSourceInstall =
+ installSource === "vcs" || installSource === "local_path";
+ const loadingInstallSource = installSource === "loading";
+ const unknownInstallSource = isUnknownInstallSource(installSource);
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
- const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
+ const fadeInitial = prefersReducedMotion
+ ? { opacity: 1 }
+ : { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
- const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
+ const fadeExit = prefersReducedMotion
+ ? { opacity: 1 }
+ : { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
@@ -133,9 +181,9 @@ export function UpdateStudioInstructions({
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
- !windows
- ? "text-foreground"
- : "text-muted-foreground hover:text-emerald-600",
+ windows
+ ? "text-muted-foreground hover:text-emerald-600"
+ : "text-foreground",
)}
aria-pressed={!windows}
>
@@ -143,43 +191,157 @@ export function UpdateStudioInstructions({
-
-
- {getStudioUpdateInstructionLine(shell)}
-
-
-
-
- If that fails or unsloth studio update is unavailable, run:
-
-
-
+ {loadingInstallSource ? (
+
+ Checking how Studio was installed…
+
+ ) : localInstallSource ? (
+ <>
+
+ Source or local install detected. To avoid replacing it with PyPI,
+ update from the checkout or source you originally installed from.
+
+ {checkoutInstallSource ? (
+ <>
+
+ Pull latest changes from your Unsloth repo checkout, then update
+ Studio locally:
+
+
+
+
+ If the Studio update command is unavailable, run the local
+ installer from that checkout:
+
+
+
+
+
+
+ >
+ ) : null}
+ {packagedSourceInstall ? (
+ <>
+
+ This looks like a source or VCS package install. Reinstall from
+ the original local path or Git URL you used.
+
+
+ If you still have the Unsloth repo checkout, run the local
+ installer from that checkout:
+
+
+
+
+
+
+ >
+ ) : null}
+
+ Restart Studio after updating for changes to take effect.
+
+ >
+ ) : unknownInstallSource ? (
+ <>
+
+ Studio could not detect how it was installed. Check how you
+ installed Studio first, then choose the matching update path.
+
+
+ For curl or PyPI installs, run:
+
-
-
-
- Restart Studio after updating for changes to take effect.
-
+
+ For local checkout installs, update from that checkout instead and
+ use the local update command:
+
+
+
+ Restart Studio after updating for changes to take effect.
+
+ >
+ ) : (
+ <>
+
+
+ {getStudioUpdateInstructionLine(shell)}
+
+
+
+
+ If that fails or unsloth studio update is unavailable, run:
+
+
+
+
+
+
+
+ Restart Studio after updating for changes to take effect.
+
+ >
+ )}
);
}
diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx
index 9629900237..68d82a804a 100644
--- a/studio/frontend/src/features/settings/tabs/about-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx
@@ -1,12 +1,12 @@
// 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 { ShutdownDialog } from "@/components/shutdown-dialog";
-import { UpdateStudioInstructions } from "../components/update-studio-instructions";
+import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
-import { apiUrl } from "@/lib/api-base";
-import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
+import { getAuthToken } from "@/features/auth";
+import { removeTrainingUnloadGuard } from "@/features/training";
+import { apiUrl, isTauri } from "@/lib/api-base";
import {
ArrowUpRight01Icon,
Book03Icon,
@@ -18,28 +18,108 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
+import {
+ type UpdateInstallSource,
+ UpdateStudioInstructions,
+} from "../components/update-studio-instructions";
+
+type ApiObject = Record;
+
+const INSTALL_SOURCE_KEY = "install_source";
+
+const UPDATE_INSTALL_SOURCES = new Set([
+ "pypi",
+ "editable",
+ "local_path",
+ "vcs",
+ "local_repo",
+ "unknown",
+]);
+
+function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
+ return (
+ typeof value === "string" &&
+ UPDATE_INSTALL_SOURCES.has(value as UpdateInstallSource)
+ );
+}
+
+async function fetchStudioVersions(): Promise<{
+ packageVersion: string | null;
+ studioVersion: string | null;
+}> {
+ try {
+ const res = await fetch(apiUrl("/api/health"));
+ if (!res.ok) {
+ return { packageVersion: null, studioVersion: null };
+ }
+ const data = (await res.json()) as ApiObject;
+ const packageVersion = data.version;
+ const studioVersion = data.studio_version;
+ return {
+ packageVersion:
+ typeof packageVersion === "string" ? packageVersion : null,
+ studioVersion: typeof studioVersion === "string" ? studioVersion : null,
+ };
+ } catch {
+ return { packageVersion: null, studioVersion: null };
+ }
+}
+
+async function fetchInstallSource(): Promise {
+ if (isTauri) {
+ return "unknown";
+ }
+
+ const token = getAuthToken();
+ if (!token) {
+ return "unknown";
+ }
+
+ try {
+ const headers = new Headers();
+ headers.set("Authorization", `Bearer ${token}`);
+ const res = await fetch(apiUrl("/api/studio/install-source"), { headers });
+ if (!res.ok) {
+ return "unknown";
+ }
+ const data = (await res.json()) as ApiObject;
+ const installSource = data[INSTALL_SOURCE_KEY];
+ return isUpdateInstallSource(installSource) ? installSource : "unknown";
+ } catch {
+ return "unknown";
+ }
+}
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
- const [version, setVersion] = useState("dev");
+ const [packageVersion, setPackageVersion] = useState("dev");
+ const [studioVersion, setStudioVersion] = useState("dev");
+ const [installSource, setInstallSource] = useState<
+ UpdateInstallSource | "loading"
+ >("loading");
useEffect(() => {
let canceled = false;
- (async () => {
- try {
- const res = await fetch(apiUrl("/api/health"));
- if (!res.ok) return;
- const data = (await res.json()) as { version?: string };
- if (!canceled && data.version) {
- setVersion(data.version);
- }
- } catch {
- // fall back to dev label
+ fetchStudioVersions().then((nextVersions) => {
+ if (canceled) {
+ return;
}
- })();
+ if (nextVersions.packageVersion) {
+ setPackageVersion(nextVersions.packageVersion);
+ }
+ if (nextVersions.studioVersion) {
+ setStudioVersion(nextVersions.studioVersion);
+ }
+ });
+
+ fetchInstallSource().then((nextInstallSource) => {
+ if (!canceled) {
+ setInstallSource(nextInstallSource);
+ }
+ });
return () => {
canceled = true;
@@ -56,14 +136,25 @@ export function AboutTab() {
-
- {version}
+
+
+ {studioVersion}
+
+
+
+
+ {packageVersion}
+
-
+
@@ -99,7 +190,10 @@ export function AboutTab() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
-
+
Report an issue
@@ -108,7 +202,7 @@ export function AboutTab() {
diff --git a/studio/frontend/src/features/training/hooks/use-training-unload-guard.ts b/studio/frontend/src/features/training/hooks/use-training-unload-guard.ts
index 78c1e3d5c3..1808cf5888 100644
--- a/studio/frontend/src/features/training/hooks/use-training-unload-guard.ts
+++ b/studio/frontend/src/features/training/hooks/use-training-unload-guard.ts
@@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
-import { useTrainingRuntimeStore } from "@/features/training";
+import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
@@ -13,14 +13,18 @@ let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
export function useTrainingUnloadGuard() {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
- if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
+ if (!useTrainingRuntimeStore.getState().isTrainingRunning) {
+ return;
+ }
e.preventDefault();
e.returnValue = "";
};
currentHandler = handler;
window.addEventListener("beforeunload", handler);
return () => {
- if (currentHandler === handler) currentHandler = null;
+ if (currentHandler === handler) {
+ currentHandler = null;
+ }
window.removeEventListener("beforeunload", handler);
};
}, []);
diff --git a/studio/frontend/src/features/training/index.ts b/studio/frontend/src/features/training/index.ts
index 571cfb6ff5..83d8edba75 100644
--- a/studio/frontend/src/features/training/index.ts
+++ b/studio/frontend/src/features/training/index.ts
@@ -16,7 +16,11 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
export { uploadTrainingDataset } from "./api/datasets-api";
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
-export type { TrainingPhase, TrainingViewData, TrainingSeriesPoint } from "./types/runtime";
+export type {
+ TrainingPhase,
+ TrainingViewData,
+ TrainingSeriesPoint,
+} from "./types/runtime";
export type {
TrainingRunSummary,
TrainingRunListResponse,
diff --git a/studio/frontend/src/hooks/use-web-update-check.ts b/studio/frontend/src/hooks/use-web-update-check.ts
new file mode 100644
index 0000000000..1ad0fe544c
--- /dev/null
+++ b/studio/frontend/src/hooks/use-web-update-check.ts
@@ -0,0 +1,158 @@
+// 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 { apiUrl, isTauri } from "@/lib/api-base";
+import { useCallback, useEffect, useState } from "react";
+
+const WEB_UPDATE_CHECK_DELAY_MS = 5000;
+const DISMISS_PREFIX = "unsloth_web_update_dismissed";
+const CAN_SHOW_KEY = "can_show_web_notification";
+const UPDATE_AVAILABLE_KEY = "update_available";
+const INSTALL_SOURCE_KEY = "install_source";
+const LATEST_VERSION_KEY = "latest_version";
+const CURRENT_VERSION_KEY = "current_version";
+const CHECKED_AT_KEY = "checked_at";
+
+type ApiObject = Record;
+
+export type WebUpdateInstallSource =
+ | "pypi"
+ | "editable"
+ | "local_path"
+ | "vcs"
+ | "local_repo"
+ | "unknown";
+
+export interface WebUpdateStatus {
+ currentVersion: string;
+ latestVersion: string;
+ installSource: "pypi";
+ checkedAt: string;
+}
+
+interface UseWebUpdateCheckOptions {
+ enabled?: boolean;
+ delayMs?: number;
+}
+
+function stringField(value: ApiObject, key: string): string | null {
+ const field = value[key];
+ return typeof field === "string" ? field : null;
+}
+
+function toDisplayableUpdateStatus(value: unknown): WebUpdateStatus | null {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+
+ const status = value as ApiObject;
+ const latestVersion = stringField(status, LATEST_VERSION_KEY);
+ const currentVersion = stringField(status, CURRENT_VERSION_KEY);
+ const checkedAt = stringField(status, CHECKED_AT_KEY);
+ if (
+ status[CAN_SHOW_KEY] !== true ||
+ status[UPDATE_AVAILABLE_KEY] !== true ||
+ status[INSTALL_SOURCE_KEY] !== "pypi" ||
+ !latestVersion ||
+ !currentVersion ||
+ !checkedAt
+ ) {
+ return null;
+ }
+
+ return {
+ currentVersion,
+ latestVersion,
+ installSource: "pypi",
+ checkedAt,
+ };
+}
+
+function dismissalKey(status: WebUpdateStatus): string {
+ return `${DISMISS_PREFIX}:${status.installSource}:${status.latestVersion}`;
+}
+
+function isDismissed(status: WebUpdateStatus): boolean {
+ if (typeof window === "undefined") {
+ return true;
+ }
+ try {
+ return window.localStorage.getItem(dismissalKey(status)) !== null;
+ } catch {
+ return false;
+ }
+}
+
+function markDismissed(status: WebUpdateStatus): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ window.localStorage.setItem(dismissalKey(status), String(Date.now()));
+ } catch {
+ // Ignore storage failures; the banner can still be dismissed in-memory.
+ }
+}
+
+async function fetchDisplayableUpdateStatus(): Promise {
+ const token = getAuthToken();
+ if (!token) {
+ return null;
+ }
+
+ const headers = new Headers();
+ headers.set("Authorization", `Bearer ${token}`);
+ const res = await fetch(apiUrl("/api/studio/update-status"), { headers });
+ if (!res.ok) {
+ return null;
+ }
+
+ return toDisplayableUpdateStatus(await res.json());
+}
+
+export function useWebUpdateCheck({
+ enabled = true,
+ delayMs = WEB_UPDATE_CHECK_DELAY_MS,
+}: UseWebUpdateCheckOptions = {}) {
+ const [status, setStatus] = useState(null);
+
+ useEffect(() => {
+ if (isTauri || !enabled || !getAuthToken()) {
+ const clearTimer = window.setTimeout(() => setStatus(null), 0);
+ return () => window.clearTimeout(clearTimer);
+ }
+
+ let canceled = false;
+ const timer = window.setTimeout(() => {
+ fetchDisplayableUpdateStatus()
+ .then((nextStatus) => {
+ if (canceled) {
+ return;
+ }
+ setStatus(nextStatus && !isDismissed(nextStatus) ? nextStatus : null);
+ })
+ .catch(() => {
+ if (!canceled) {
+ setStatus(null);
+ }
+ });
+ }, delayMs);
+
+ return () => {
+ canceled = true;
+ window.clearTimeout(timer);
+ };
+ }, [delayMs, enabled]);
+
+ const dismiss = useCallback(() => {
+ setStatus((current) => {
+ if (current) {
+ markDismissed(current);
+ }
+ return null;
+ });
+ }, []);
+
+ return { status: enabled && !isTauri ? status : null, dismiss };
+}