diff --git a/studio/backend/routes/llama.py b/studio/backend/routes/llama.py
index b30c0f5aea..47a5bc1dd3 100644
--- a/studio/backend/routes/llama.py
+++ b/studio/backend/routes/llama.py
@@ -31,6 +31,7 @@ class LlamaUpdateJob(BaseModel):
from_tag: Optional[str] = None
to_tag: Optional[str] = None
error: Optional[str] = None
+ progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
started_at: Optional[str] = None
finished_at: Optional[str] = None
diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py
index 05a107377c..d21d653b76 100644
--- a/studio/backend/tests/test_llama_cpp_update.py
+++ b/studio/backend/tests/test_llama_cpp_update.py
@@ -28,6 +28,55 @@ import utils.llama_cpp_update as upd # noqa: E402
MARKER = "UNSLOTH_PREBUILT_INFO.json"
+class _FakeInstallerPopen:
+ """Stands in for the streamed installer process in _run_update."""
+
+ def __init__(
+ self,
+ cmd,
+ *,
+ returncode = 0,
+ lines = None,
+ on_start = None,
+ captured_kwargs = None,
+ **kwargs,
+ ):
+ if captured_kwargs is not None:
+ captured_kwargs.update(kwargs)
+ if on_start is not None:
+ on_start(list(cmd))
+ self.returncode = returncode
+ self.stdout = iter(lines or [])
+
+ def wait(self):
+ return self.returncode
+
+ def kill(self):
+ pass
+
+
+def _patch_installer_popen(
+ monkeypatch,
+ *,
+ returncode = 0,
+ lines = None,
+ on_start = None,
+ captured_kwargs = None,
+):
+ monkeypatch.setattr(
+ upd.subprocess,
+ "Popen",
+ lambda cmd, **kw: _FakeInstallerPopen(
+ cmd,
+ returncode = returncode,
+ lines = lines,
+ on_start = on_start,
+ captured_kwargs = captured_kwargs,
+ **kw,
+ ),
+ )
+
+
def _write_install(
dir_: Path,
tag: str,
@@ -260,14 +309,15 @@ def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
- # Status polls probe `llama-server --version`; keep the installer argv.
- if "--version" in cmd:
- return _Proc()
- captured["cmd"] = cmd
- _write_install(install_dir, "b9585") # installer writes the marker
+ assert "--version" in cmd # only status polls still use run()
return _Proc()
+ def _on_start(cmd):
+ captured["cmd"] = cmd
+ _write_install(install_dir, "b9585") # installer writes the marker
+
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
+ _patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@@ -298,21 +348,27 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
stdout = "installed"
stderr = ""
- def _fake_run(cmd, **kwargs):
- cmd = list(cmd)
- # Status polls probe `llama-server --version`; keep the installer argv.
- if "--version" in cmd:
- return _Proc()
+ def _on_start(cmd):
captured["cmd"] = cmd
# Simulate the installer writing a new marker with the latest tag.
_write_install(install_dir, "b9518")
- return _Proc()
- monkeypatch.setattr(upd.subprocess, "run", _fake_run)
+ popen_kwargs: dict = {}
+ _patch_installer_popen(
+ monkeypatch,
+ lines = [
+ "[llama-prebuilt] resolving release\n",
+ "Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
+ "Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
+ ],
+ on_start = _on_start,
+ captured_kwargs = popen_kwargs,
+ )
res = upd.start_update()
assert res["started"] is True
assert res["job"]["from_tag"] == "b9493"
+ assert res["job"]["progress"] == 0.0
# Wait for the background worker.
deadline = time.time() + 10
@@ -328,6 +384,10 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
assert str(install_dir) in captured["cmd"]
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
assert "unslothai/llama.cpp" in captured["cmd"]
+ # Progress lines were parsed and success pins progress at 1.0.
+ assert job["progress"] == 1.0
+ # The worker asks the installer for fine-grained progress milestones.
+ assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
@@ -336,12 +396,7 @@ def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
- class _Proc:
- returncode = 2
- stdout = ""
- stderr = "boom: network error"
-
- monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
+ _patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"])
res = upd.start_update()
assert res["started"] is True
@@ -410,14 +465,15 @@ def _capture_install_cmd(
def _fake_run(cmd, **kwargs):
cmd = list(cmd)
- # Status polls probe `llama-server --version`; keep the installer argv.
- if "--version" in cmd:
- return _Proc()
- captured["cmd"] = cmd
- _write_install(install_dir, latest, repo = repo, asset = asset)
+ assert "--version" in cmd # only status polls still use run()
return _Proc()
+ def _on_start(cmd):
+ captured["cmd"] = cmd
+ _write_install(install_dir, latest, repo = repo, asset = asset)
+
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
+ _patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True, res
@@ -535,18 +591,12 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
seen = {}
- class _Proc:
- returncode = 0
- stdout = "ok"
- stderr = ""
-
- def _fake_run(cmd, **kwargs):
+ def _on_start(cmd):
# The maintenance flag must be set while the installer runs.
seen["flag_during_install"] = backend._llama_update_in_progress
_write_install(install_dir, "b9518")
- return _Proc()
- monkeypatch.setattr(upd.subprocess, "run", _fake_run)
+ _patch_installer_popen(monkeypatch, on_start = _on_start)
res = upd.start_update()
assert res["started"] is True
@@ -571,12 +621,7 @@ def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_pa
backend = _FakeBackend()
_inject_backend(monkeypatch, backend)
- class _Proc:
- returncode = 1
- stdout = ""
- stderr = "boom"
-
- monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
+ _patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"])
res = upd.start_update()
assert res["started"] is True
@@ -606,16 +651,7 @@ def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path):
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
- class _Proc:
- returncode = 0
- stdout = "ok"
- stderr = ""
-
- def _fake_run(cmd, **kwargs):
- _write_install(install_dir, "b9518")
- return _Proc()
-
- monkeypatch.setattr(upd.subprocess, "run", _fake_run)
+ _patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518"))
res = upd.start_update()
assert res["started"] is True
diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py
index b138b29af3..55bf4b3d84 100644
--- a/studio/backend/utils/llama_cpp_update.py
+++ b/studio/backend/utils/llama_cpp_update.py
@@ -59,10 +59,17 @@ _job: dict = {
"from_tag": None,
"to_tag": None,
"error": None,
+ "progress": None,
"started_at": None,
"finished_at": None,
}
+# Matches the installer's download progress lines, e.g.
+# "Downloading x.zip: 35.0% (12.3 MiB/35.1 MiB) at 8.2 MiB/s".
+_PROGRESS_LINE_RE = re.compile(r"(\d+(?:\.\d+)?)%\s*\(")
+# The download dominates the update; extract/validate fill the last slice.
+_DOWNLOAD_PROGRESS_CEILING = 0.95
+
def _utcnow() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -357,15 +364,46 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
]
cmd.extend(_rocm_install_args(asset))
logger.info("llama update: installing", cmd = " ".join(cmd))
- proc = subprocess.run(
+ # Stream the installer output so download percent lines feed
+ # job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP.
+ env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
+ proc = subprocess.Popen(
cmd,
- capture_output = True,
+ stdout = subprocess.PIPE,
+ stderr = subprocess.STDOUT,
text = True,
- timeout = _INSTALL_TIMEOUT_SECONDS,
+ env = env,
)
- if proc.returncode != 0:
- tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
- raise RuntimeError(f"installer exited {proc.returncode}: {tail or 'no output'}")
+ timed_out = threading.Event()
+
+ def _kill_on_timeout() -> None:
+ timed_out.set()
+ proc.kill()
+
+ watchdog = threading.Timer(_INSTALL_TIMEOUT_SECONDS, _kill_on_timeout)
+ watchdog.daemon = True
+ watchdog.start()
+ tail_lines: list[str] = []
+ try:
+ assert proc.stdout is not None
+ for line in proc.stdout:
+ tail_lines.append(line)
+ if len(tail_lines) > 80:
+ del tail_lines[0]
+ m = _PROGRESS_LINE_RE.search(line)
+ if m is None:
+ continue
+ fraction = min(float(m.group(1)) / 100.0, 1.0) * _DOWNLOAD_PROGRESS_CEILING
+ with _job_lock:
+ _job["progress"] = max(_job.get("progress") or 0.0, fraction)
+ returncode = proc.wait()
+ finally:
+ watchdog.cancel()
+ if timed_out.is_set():
+ raise RuntimeError(f"installer timed out after {_INSTALL_TIMEOUT_SECONDS}s")
+ if returncode != 0:
+ tail = "".join(tail_lines).strip()[-1500:]
+ raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}")
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop caches so the next
# status read reflects the freshly installed tag.
@@ -382,6 +420,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
),
to_tag = new_tag,
error = None,
+ progress = 1.0,
finished_at = _utcnow(),
)
logger.info("llama update: success", to_tag = new_tag)
@@ -467,6 +506,7 @@ def start_update() -> dict:
from_tag = from_tag,
to_tag = None,
error = None,
+ progress = 0.0,
started_at = _utcnow(),
finished_at = None,
)
@@ -491,6 +531,7 @@ def _reset_job_for_tests() -> None:
from_tag = None,
to_tag = None,
error = None,
+ progress = None,
started_at = None,
finished_at = None,
)
diff --git a/studio/backend/utils/studio_version.py b/studio/backend/utils/studio_version.py
index 98c48fe45c..9c18070fbb 100644
--- a/studio/backend/utils/studio_version.py
+++ b/studio/backend/utils/studio_version.py
@@ -15,6 +15,7 @@ _DEV_VERSION = "dev"
_GIT_TIMEOUT_SECONDS = 1.0
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
+_GIT_BRANCH_RE = re.compile(r"^[0-9A-Za-z._/-]+$")
_MAX_VERSION_LENGTH = 64
@@ -71,6 +72,35 @@ def _exact_git_studio_tag(repo_root: Path) -> str | None:
return tag if is_valid_studio_release_version(tag) else None
+def _git_branch(repo_root: Path) -> str | None:
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd = repo_root,
+ check = False,
+ stdout = subprocess.PIPE,
+ stderr = subprocess.DEVNULL,
+ text = True,
+ timeout = _GIT_TIMEOUT_SECONDS,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+
+ if result.returncode != 0:
+ return None
+
+ branch = result.stdout.strip()
+ # "HEAD" means detached, e.g. a tag or commit checkout.
+ if (
+ not branch
+ or branch == "HEAD"
+ or len(branch) > _MAX_VERSION_LENGTH
+ or _GIT_BRANCH_RE.fullmatch(branch) is None
+ ):
+ return None
+ return branch
+
+
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
@@ -81,7 +111,10 @@ def get_studio_version(repo_root: Path | None = None) -> str:
if _is_source_checkout(resolved_repo_root):
git_tag = _exact_git_studio_tag(resolved_repo_root)
- return git_tag if git_tag is not None else _DEV_VERSION
+ if git_tag is not None:
+ return git_tag
+ branch = _git_branch(resolved_repo_root)
+ return f"GitHub {branch}" if branch is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):
diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx
index 7baa69571c..f1a200b09b 100644
--- a/studio/frontend/src/components/app-sidebar.tsx
+++ b/studio/frontend/src/components/app-sidebar.tsx
@@ -60,6 +60,7 @@ import {
Logout05Icon,
MoreVerticalIcon,
Search01Icon,
+ PlusSignIcon,
PowerIcon,
PencilEdit02Icon,
LayoutAlignLeftIcon,
@@ -558,7 +559,8 @@ export function AppSidebar() {
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
- variant === "project" ? "pl-[39px]" : "pl-3",
+ // pl-3.5 starts the title at the same x as the Recents label text.
+ variant === "project" ? "pl-[39px]" : "pl-3.5",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
@@ -837,7 +839,28 @@ export function AppSidebar() {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
- />
+ className="group/projects-item relative"
+ >
+
+
-
+
{displayTitle}Unsloth
diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx
index c8a4dd1e40..83320f8e15 100644
--- a/studio/frontend/src/components/llama-update-banner.tsx
+++ b/studio/frontend/src/components/llama-update-banner.tsx
@@ -3,9 +3,10 @@
import { Button } from "@/components/ui/button";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
+import { useShowLlamaUpdateBanner } from "@/hooks/use-llama-update-pref";
import { toast } from "@/lib/toast";
import { AnimatePresence, motion } from "motion/react";
-import { type ReactElement, useEffect, useRef } from "react";
+import type { ReactElement } from "react";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
@@ -15,15 +16,19 @@ interface LlamaUpdateBannerProps {
/**
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
- * newer prebuilt is detected and stays up until dismissed (click outside / X)
- * or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
+ * newer prebuilt is detected and stays up until the user explicitly acts on it
+ * (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place
+ * via POST /api/llama/update. Can be turned off entirely in Settings ->
+ * General -> Notifications (on by default).
*/
export function LlamaUpdateBanner({
enabled = true,
}: LlamaUpdateBannerProps): ReactElement | null {
- const { status, visible, applying, apply, dismiss } = useLlamaUpdateCheck({
- enabled,
- });
+ const showBannerPref = useShowLlamaUpdateBanner();
+ const { status, visible, applying, apply, dismiss, snooze } =
+ useLlamaUpdateCheck({
+ enabled: enabled && showBannerPref,
+ });
async function handleUpdate() {
const result = await apply();
@@ -40,30 +45,12 @@ export function LlamaUpdateBanner({
const show =
visible && status != null && (status.update_available || applying);
- const bannerRef = useRef(null);
-
- // Dismiss when the user clicks anything outside the banner. Kept off while an
- // update is applying so the progress stays visible.
- useEffect(() => {
- if (!show || applying) return;
- function onPointerDown(event: PointerEvent) {
- if (
- bannerRef.current &&
- !bannerRef.current.contains(event.target as Node)
- ) {
- dismiss();
- }
- }
- document.addEventListener("pointerdown", onPointerDown, true);
- return () =>
- document.removeEventListener("pointerdown", onPointerDown, true);
- }, [show, applying, dismiss]);
+ const updateProgress = status?.job.progress ?? null;
return (
{show ? (
-
+
{applying ? null : (
) : null}
diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx
index 1eb3f81d10..8e373c82a0 100644
--- a/studio/frontend/src/components/tauri/startup-screen.tsx
+++ b/studio/frontend/src/components/tauri/startup-screen.tsx
@@ -228,7 +228,7 @@ function RepairingContent({
-
Updating existing Studio install...
+
Updating existing Unsloth install...
{latest && (
{latest}
)}
diff --git a/studio/frontend/src/components/ui/tooltip.tsx b/studio/frontend/src/components/ui/tooltip.tsx
index 954b1871f3..cbc1a09a8f 100644
--- a/studio/frontend/src/components/ui/tooltip.tsx
+++ b/studio/frontend/src/components/ui/tooltip.tsx
@@ -98,9 +98,27 @@ function TooltipContent({
}: React.ComponentProps & {
variant?: TooltipVariant;
}) {
+ // Single-line compact tooltips render as a full pill; wrapped ones keep
+ // the squarer corners so tall pills do not look like capsules. A ref
+ // callback measures on mount: Radix mounts the portal content without
+ // re-rendering this wrapper, so an effect here would never see the node.
+ const measureRef = useCallback(
+ (el: HTMLDivElement | null) => {
+ if (!el || variant !== "default") return;
+ const cs = getComputedStyle(el);
+ const lineHeight = Number.parseFloat(cs.lineHeight) || 16;
+ const innerHeight =
+ el.clientHeight -
+ Number.parseFloat(cs.paddingTop) -
+ Number.parseFloat(cs.paddingBottom);
+ el.classList.toggle("rounded-full!", innerHeight < lineHeight * 1.5);
+ },
+ [variant],
+ );
return (
s.deviceType);
+ const installCmd =
+ deviceType === "windows"
+ ? STUDIO_INSTALL_WINDOWS_CMD
+ : STUDIO_INSTALL_UNIX_CMD;
const [copiedVersion, setCopiedVersion] = useState(null);
const dismissTimerRef = useRef | null>(null);
@@ -36,7 +44,7 @@ export function WebUpdateBanner({
}
async function handleCopyCommand() {
- if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
+ if (!(await copyToClipboard(installCmd))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
@@ -89,8 +97,8 @@ export function WebUpdateBanner({
Package update available: {status.latestVersion}
- Installed package: {status.currentVersion}. To update Studio,
- run this in your terminal, then restart Studio.
+ Installed package: {status.currentVersion}. To update Unsloth,
+ run this in your terminal, then restart Unsloth.
diff --git a/studio/frontend/src/config/env.ts b/studio/frontend/src/config/env.ts
index 92dad2ae27..61ab111d45 100644
--- a/studio/frontend/src/config/env.ts
+++ b/studio/frontend/src/config/env.ts
@@ -46,12 +46,28 @@ export async function fetchDeviceType(): Promise {
if (fetched) return usePlatformStore.getState().deviceType;
try {
- const res = await fetch(apiUrl("/api/health"));
+ // /api/health only reports the server's device_type to authed callers.
+ // Read the token from storage directly: importing features/auth here
+ // would be an import cycle (auth/session imports this store).
+ const token =
+ typeof window === "undefined"
+ ? null
+ : localStorage.getItem("unsloth_auth_token");
+ const res = await fetch(apiUrl("/api/health"), {
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
+ });
if (res.ok) {
const data = (await res.json()) as { device_type?: string; chat_only?: boolean };
const deviceType = data.device_type ?? detectLocalPlatform();
const chatOnly = data.chat_only ?? false;
- usePlatformStore.setState({ deviceType, chatOnly, fetched: true });
+ // Cache only a server-reported platform. Unauthenticated responses fall
+ // back to the browser platform, which can differ from the host (WSL,
+ // SSH); keeping fetched=false retries once a token exists.
+ usePlatformStore.setState({
+ deviceType,
+ chatOnly,
+ fetched: data.device_type !== undefined,
+ });
return deviceType;
}
} catch {
diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts
index 6356f89378..144e85d40a 100644
--- a/studio/frontend/src/features/auth/api.ts
+++ b/studio/frontend/src/features/auth/api.ts
@@ -175,7 +175,7 @@ export async function authFetch(
"You appear to be offline. Check your network connection and try again.",
);
}
- throw new Error("Studio isn't running -- please relaunch it.");
+ throw new Error("Unsloth isn't running -- please relaunch it.");
}
throw err;
}
diff --git a/studio/frontend/src/features/auth/tauri-auto-auth.ts b/studio/frontend/src/features/auth/tauri-auto-auth.ts
index 760af26395..e825d5de31 100644
--- a/studio/frontend/src/features/auth/tauri-auto-auth.ts
+++ b/studio/frontend/src/features/auth/tauri-auto-auth.ts
@@ -26,7 +26,7 @@ let pending: { promise: Promise; force: boolean } | null = null;
let lastTauriAuthFailure: string | null = null;
const TAURI_AUTH_FAILURE_FALLBACK =
- "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
+ "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
const BACKEND_NOT_READY_MESSAGE = "Backend is not ready";
function authFailureMessage(error: unknown): string {
diff --git a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
index a679e8ed83..146a12bd40 100644
--- a/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
+++ b/studio/frontend/src/features/chat/adapters/studio-web-speech-dictation-adapter.ts
@@ -18,7 +18,7 @@ const describeMediaError = (error: unknown): string => {
return "Dictation could not access the microphone.";
}
if (error.name === "NotAllowedError") {
- return "Microphone access is blocked. Allow microphone access for this Studio page, then try again.";
+ return "Microphone access is blocked. Allow microphone access for this Unsloth page, then try again.";
}
if (error.name === "NotFoundError") {
return "No microphone was found for dictation.";
@@ -31,7 +31,7 @@ const describeMediaError = (error: unknown): string => {
const describeSpeechError = (error: string, message?: string): string => {
if (error === "not-allowed") {
- return "Speech recognition was blocked by the browser. Check microphone permissions for this Studio page.";
+ return "Speech recognition was blocked by the browser. Check microphone permissions for this Unsloth page.";
}
if (error === "service-not-allowed") {
return "Speech recognition is blocked by the browser speech service.";
diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
index e35a8e35e0..0594b55083 100644
--- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx
+++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx
@@ -1511,7 +1511,7 @@ export function ChatProvidersSettings({
Connections
- Manage model connections for chat through the Studio proxy.
+ Manage model connections for chat.
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index 03c183fe76..5751a1a426 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -325,6 +325,7 @@ function CollapsibleSection({
label,
labelHref,
headerAction,
+ onLabelClick,
children,
defaultOpen = false,
first = false,
@@ -342,6 +343,8 @@ function CollapsibleSection({
* nested in a button.
*/
headerAction?: ReactNode;
+ /** When set, clicking the label runs this instead of toggling collapse. */
+ onLabelClick?: () => void;
children?: ReactNode;
defaultOpen?: boolean;
first?: boolean;
@@ -395,7 +398,7 @@ function CollapsibleSection({
diff --git a/studio/frontend/src/features/chat/tour/steps.tsx b/studio/frontend/src/features/chat/tour/steps.tsx
index de27e93c88..cbbb7bae4f 100644
--- a/studio/frontend/src/features/chat/tour/steps.tsx
+++ b/studio/frontend/src/features/chat/tour/steps.tsx
@@ -28,7 +28,7 @@ export function buildChatTourSteps({
body: (
<>
This selects what’s loaded for inference. Hub = base models. Fine-tuned
- = trained Studio outputs, including LoRA adapters and full finetunes.
+ = trained Unsloth outputs, including LoRA adapters and full finetunes.
>
),
},
@@ -38,7 +38,7 @@ export function buildChatTourSteps({
title: "Two tabs",
body: (
<>
- Hub: search Hugging Face models. Fine-tuned: local Studio outputs you’ve
+ Hub: search Hugging Face models. Fine-tuned: local Unsloth outputs you’ve
trained or exported. If results look off, compare base vs fine-tuned
outputs to see what changed.
>
diff --git a/studio/frontend/src/features/chat/utils/chat-history-storage.ts b/studio/frontend/src/features/chat/utils/chat-history-storage.ts
index 0e9b30a5cc..2023ae30b1 100644
--- a/studio/frontend/src/features/chat/utils/chat-history-storage.ts
+++ b/studio/frontend/src/features/chat/utils/chat-history-storage.ts
@@ -134,7 +134,7 @@ export function isExpectedBackgroundChatStorageError(error: unknown): boolean {
(error.message === "Invalid or expired token" ||
error.message === "Not authenticated" ||
error.message === "Request failed (401)" ||
- error.message === "Studio isn't running -- please relaunch it.")
+ error.message === "Unsloth isn't running -- please relaunch it.")
);
}
diff --git a/studio/frontend/src/features/hub/download-manager/api.ts b/studio/frontend/src/features/hub/download-manager/api.ts
index ca987f1643..2c55b90edd 100644
--- a/studio/frontend/src/features/hub/download-manager/api.ts
+++ b/studio/frontend/src/features/hub/download-manager/api.ts
@@ -12,14 +12,14 @@ function parseErrorText(status: number, body: unknown): string {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (status === 405) {
- return `${formatted || "Method Not Allowed"} - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.`;
+ return `${formatted || "Method Not Allowed"} - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.`;
}
if (formatted) return formatted;
const message = (body as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
if (status === 405) {
- return "Method Not Allowed - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.";
+ return "Method Not Allowed - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.";
}
return `Request failed (${status})`;
}
@@ -137,7 +137,7 @@ const DOWNLOAD_TRANSPORT_CAPABILITIES_FALLBACK: DownloadTransportCapabilities =
http: { available: true, reason: null },
xet: {
available: null,
- reason: "Couldn't verify Xet support with the Studio backend.",
+ reason: "Couldn't verify Xet support with the Unsloth backend.",
},
};
let downloadTransportCapabilitiesCache: DownloadTransportCapabilities | null =
diff --git a/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts b/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
index 6942dfbbb9..a6518ec656 100644
--- a/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
+++ b/studio/frontend/src/features/hub/download-manager/download-api-adapter.ts
@@ -184,7 +184,7 @@ export async function effectiveTransportMode(
return preferred;
}
const reason =
- capabilities.xet.reason ?? "Studio will use HTTP downloads instead.";
+ capabilities.xet.reason ?? "Unsloth will use HTTP downloads instead.";
if (lastXetUnavailableWarningReason !== reason) {
lastXetUnavailableWarningReason = reason;
toast.warning("Xet download transport unavailable", {
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
index a3e5dbe962..1616f6f77d 100644
--- a/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-data-tab.tsx
@@ -45,7 +45,7 @@ function formatGitHubSourceMessage(execution: RecipeExecutionRecord): string {
return "Collecting repository threads before rows are available.";
}
if (source.status === "rate_limited") {
- return source.message ?? "Waiting for GitHub rate limit. Studio will resume automatically.";
+ return source.message ?? "Waiting for GitHub rate limit. Unsloth will resume automatically.";
}
return source.message ?? "Collecting repository threads before rows are available.";
}
diff --git a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
index 72624738fc..5ff9d644e7 100644
--- a/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/executions/execution-overview-tab.tsx
@@ -41,7 +41,7 @@ function formatSourceMessage(execution: RecipeExecutionRecord): string {
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` Waiting ~${formatMetricValue(source.retry_after_sec)}s.`
: "";
- return `Waiting for GitHub rate limit. Studio will resume automatically.${wait}`;
+ return `Waiting for GitHub rate limit. Unsloth will resume automatically.${wait}`;
}
return source.message ?? "Crawling GitHub source.";
}
diff --git a/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
index 563280c55f..1a194ba857 100644
--- a/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/runtime/execution-progress-island.tsx
@@ -68,7 +68,7 @@ function formatGitHubSourceSummary(
typeof source.retry_after_sec === "number" && source.retry_after_sec > 0
? ` ~${formatMetricValue(source.retry_after_sec)}s`
: "";
- return `Waiting for GitHub rate limit${wait}. Studio will resume automatically.`;
+ return `Waiting for GitHub rate limit${wait}. Unsloth will resume automatically.`;
}
if (source.status === "retrying") {
return source.message ?? "GitHub request failed; retrying automatically.";
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
index 878f9eba7c..0ed7eeed75 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx
@@ -334,8 +334,8 @@ export function GithubRepoSeedForm({
/>
{usingEnvToken
- ? "Studio detected a server env token, so saved/shared recipes can leave this blank."
- : "Blank is safest for saved/shared recipes because Studio will read the server environment at run time."}
+ ? "Unsloth detected a server env token, so saved/shared recipes can leave this blank."
+ : "Blank is safest for saved/shared recipes because Unsloth will read the server environment at run time."}
{hasToken && (
@@ -450,7 +450,7 @@ export function GithubRepoSeedForm({
- Backed by Studio's built-in github_repo seed reader. Large
+ Backed by Unsloth's built-in github_repo seed reader. Large
repos can take minutes, so start with small limits for previews.
diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx
index e73ad802de..69c4aa6ef1 100644
--- a/studio/frontend/src/features/settings/tabs/about-tab.tsx
+++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx
@@ -142,7 +142,7 @@ export function AboutTab() {
-
+
{studioVersion}
@@ -207,6 +207,37 @@ export function AboutTab() {
+
+
+
+ {t("settings.about.license.studioLicense")}
+
+
+
+
+
+ {t("settings.about.license.libraryLicense")}
+
+
+
+
+
s.autoTitle);
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
const chatOnly = usePlatformStore((s) => s.chatOnly);
+ const showLlamaUpdates = useShowLlamaUpdateBanner();
const redirectTo = `${pathname}${search}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
@@ -315,6 +322,20 @@ export function GeneralTab() {
+
+
+
+
+
+
- Studio: pick base model, dataset, hyperparams, then start training. After
+ Unsloth: pick base model, dataset, hyperparams, then start training. After
you start, you’ll see a Training view with live loss/metrics. Chat is for
testing base vs LoRA adapters. Export packages checkpoints for deployment.{" "}
diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts
index 1ac5d9768e..5c609558d6 100644
--- a/studio/frontend/src/hooks/use-llama-update-check.ts
+++ b/studio/frontend/src/hooks/use-llama-update-check.ts
@@ -5,11 +5,14 @@ import { authFetch, getAuthToken } from "@/features/auth";
import { useCallback, useEffect, useRef, useState } from "react";
// First check shortly after load, then re-surface as an hourly reminder. The
-// banner stays up until the user dismisses it (click outside / X) or updates.
+// banner stays up until the user explicitly acts on it (X, Update, or
+// Remind me later).
const FIRST_CHECK_DELAY_MS = 1000;
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
+// "Remind me later" re-surfaces sooner than the hourly reminder.
+const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes
// While an update is applying, poll the job state at this cadence.
-const JOB_POLL_INTERVAL_MS = 3000;
+const JOB_POLL_INTERVAL_MS = 1500;
export interface LlamaUpdateJob {
state: "idle" | "running" | "success" | "error";
@@ -17,6 +20,8 @@ export interface LlamaUpdateJob {
from_tag: string | null;
to_tag: string | null;
error: string | null;
+ // Download fraction (0..1) while running, 1 on success, null when unknown.
+ progress: number | null;
}
export interface LlamaUpdateStatus {
@@ -42,6 +47,7 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
from_tag: typeof job.from_tag === "string" ? job.from_tag : null,
to_tag: typeof job.to_tag === "string" ? job.to_tag : null,
error: typeof job.error === "string" ? job.error : null,
+ progress: typeof job.progress === "number" ? job.progress : null,
},
};
}
@@ -73,9 +79,10 @@ export interface LlamaApplyResult {
/**
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
- * becomes true ~1s after load and stays up until the user dismisses it (click
- * outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
- * triggers the in-place swap and tracks the job.
+ * becomes true ~1s after load and stays up until the user dismisses it (X),
+ * snoozes it ("Remind me later", ~15 min), or updates; it re-surfaces every
+ * ~hour as a reminder. `apply()` triggers the in-place swap and tracks the
+ * job.
*/
export function useLlamaUpdateCheck({
enabled = true,
@@ -84,6 +91,7 @@ export function useLlamaUpdateCheck({
const [visible, setVisible] = useState(false);
const [applying, setApplying] = useState(false);
const pollTimer = useRef | null>(null);
+ const snoozeTimer = useRef | null>(null);
const clearPollTimer = useCallback(() => {
if (pollTimer.current) {
@@ -161,6 +169,10 @@ export function useLlamaUpdateCheck({
clearTimeout(firstTimer);
clearInterval(reminder);
clearPollTimer();
+ if (snoozeTimer.current) {
+ clearTimeout(snoozeTimer.current);
+ snoozeTimer.current = null;
+ }
};
}, [enabled, surfaceIfAvailable, clearPollTimer]);
@@ -168,6 +180,16 @@ export function useLlamaUpdateCheck({
setVisible(false);
}, []);
+ // Hide now, re-check and re-surface after SNOOZE_DELAY_MS.
+ const snooze = useCallback(() => {
+ setVisible(false);
+ if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
+ snoozeTimer.current = setTimeout(() => {
+ snoozeTimer.current = null;
+ fetchStatus().then(surfaceIfAvailable);
+ }, SNOOZE_DELAY_MS);
+ }, [surfaceIfAvailable]);
+
const apply = useCallback(async (): Promise => {
if (applying) return { ok: false, error: "already running" };
setApplying(true);
@@ -219,5 +241,6 @@ export function useLlamaUpdateCheck({
applying,
apply,
dismiss,
+ snooze,
};
}
diff --git a/studio/frontend/src/hooks/use-llama-update-pref.ts b/studio/frontend/src/hooks/use-llama-update-pref.ts
new file mode 100644
index 0000000000..33a21965c4
--- /dev/null
+++ b/studio/frontend/src/hooks/use-llama-update-pref.ts
@@ -0,0 +1,49 @@
+// 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 { useSyncExternalStore } from "react";
+
+// Whether the llama.cpp update banner may appear. On by default; only an
+// explicit "false" (Settings -> General -> Notifications) disables it.
+const STORAGE_KEY = "unsloth_show_llama_update_banner";
+
+const listeners = new Set<() => void>();
+
+export function getShowLlamaUpdateBanner(): boolean {
+ try {
+ return localStorage.getItem(STORAGE_KEY) !== "false";
+ } catch {
+ return true;
+ }
+}
+
+export function setShowLlamaUpdateBanner(show: boolean): void {
+ try {
+ if (show) {
+ // Remove rather than store "true" so the default stays on.
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, "false");
+ }
+ } catch {
+ // storage unavailable
+ }
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ // Sync toggles made in another tab.
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === STORAGE_KEY) listener();
+ };
+ window.addEventListener("storage", onStorage);
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
+export function useShowLlamaUpdateBanner(): boolean {
+ return useSyncExternalStore(subscribe, getShowLlamaUpdateBanner);
+}
diff --git a/studio/frontend/src/hooks/use-tauri-backend.ts b/studio/frontend/src/hooks/use-tauri-backend.ts
index b376cb3a37..086cfc8090 100644
--- a/studio/frontend/src/hooks/use-tauri-backend.ts
+++ b/studio/frontend/src/hooks/use-tauri-backend.ts
@@ -56,23 +56,23 @@ function wait(ms: number) {
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
- ? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
- : "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
+ ? `A desktop-owned Unsloth server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
+ : "A desktop-owned Unsloth server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
- return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
+ return "The desktop-owned Unsloth backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
- ? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
- : "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
+ ? `A desktop-owned Unsloth backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.`
+ : "A desktop-owned Unsloth backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Unsloth.";
}
return preflight.port
- ? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
- : "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
+ ? `A Unsloth server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
+ : "A Unsloth server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
@@ -248,8 +248,8 @@ export function useTauriBackend() {
} else {
setBackendError(
preflight.disposition === "owned_stale"
- ? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
- : "Managed Studio install is too old. Run `unsloth studio update`.",
+ ? "Desktop-owned Unsloth backend is too old for this desktop app. Run `unsloth studio update`, then restart Unsloth."
+ : "Managed Unsloth install is too old. Run `unsloth studio update`.",
);
}
return;
@@ -304,7 +304,7 @@ export function useTauriBackend() {
if (msg.includes("already running")) {
startingRef.current = false;
setBackendError(
- "Managed server is already running but did not report a port. Restart Studio and try again.",
+ "Managed server is already running but did not report a port. Restart Unsloth and try again.",
);
return;
}
@@ -596,7 +596,7 @@ export function useTauriBackend() {
const detail =
event instanceof CustomEvent && typeof event.detail === "string"
? event.detail
- : "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
+ : "Desktop authentication failed. Update or repair the managed Unsloth install, then restart Unsloth.";
setAuthFailure(detail);
};
window.addEventListener("tauri-auth-failed", onAuthFailed);
diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts
index a0ee28fd86..19863d42bb 100644
--- a/studio/frontend/src/i18n/locales/en.ts
+++ b/studio/frontend/src/i18n/locales/en.ts
@@ -83,7 +83,7 @@ export const en = {
title: "Settings",
dialog: {
title: "Settings",
- description: "Manage your Unsloth Studio preferences.",
+ description: "Manage your Unsloth preferences.",
closeAriaLabel: "Close settings",
},
tabs: {
@@ -93,11 +93,11 @@ export const en = {
chat: "Chat",
connections: "Connections",
apiKeys: "API",
- about: "Help",
+ about: "About",
},
general: {
title: "General",
- description: "Global preferences for Unsloth Studio.",
+ description: "Global preferences for Unsloth.",
account: "Account",
huggingFaceToken: "Hugging Face token",
huggingFaceTokenDescription:
@@ -112,38 +112,44 @@ export const en = {
sectionTitle: "Helper LLM",
preloadOnStartup: "Pre-cache Helper LLM on startup",
preloadOnStartupDescription:
- "Download and cache the AI Assist helper model in the background when Studio starts. Off by default; AI Assist can still download it on demand when clicked.",
+ "Download the AI Assist helper model in the background on startup. Off by default; AI Assist can still fetch it on demand.",
disabledByEnv:
"Disabled by UNSLOTH_HELPER_MODEL_DISABLE in the backend environment.",
loadError: "Failed to load Helper LLM settings.",
saveError: "Failed to save Helper LLM settings.",
},
+ notifications: {
+ sectionTitle: "Notifications",
+ showLlamaUpdates: "llama.cpp update notifications",
+ showLlamaUpdatesDescription:
+ "Notify when a newer llama.cpp build is available. Turn off if you only train.",
+ },
gettingStarted: "Getting started",
startOnboarding: "Start onboarding",
startOnboardingDescription:
- "Open the setup wizard again without changing your account.",
+ "Reopen the setup wizard without changing your account.",
startOnboardingAction: "Start onboarding",
uploads: {
sectionTitle: "Uploads",
maxUploadSize: "Training dataset upload cap",
maxUploadSizeDescription:
- "Applies to training dataset uploads. Default is {defaultSize} MB.",
+ "Default is {defaultSize} MB.",
},
resetPreferences: {
sectionTitle: "Danger zone",
label: "Reset all local preferences",
description:
- "Clears local-only preferences. Chats, API access, and DB-backed settings are not affected.",
+ "Clears local-only preferences. Chats, API access, and DB-backed settings are kept.",
action: "Reset preferences",
confirmTitle: "Reset all local preferences?",
confirmDescription:
- "This clears local-only preferences, then reloads Studio. Chats, API access, and DB-backed settings are not affected.",
+ "Clears local-only preferences and reloads Unsloth. Chats, API access, and DB-backed settings are kept.",
confirmAction: "Reset and reload",
},
},
profile: {
title: "Profile",
- description: "Update how your profile appears in Studio.",
+ description: "How your profile appears in Unsloth.",
changePicture: "Change profile picture",
displayName: "Display name",
nameSaved: "Profile name saved",
@@ -163,7 +169,7 @@ export const en = {
theme: {
title: "Theme",
label: "Color scheme",
- description: "Choose light, dark, or follow your system.",
+ description: "Light, dark, or follow your system.",
system: "System",
light: "Light",
dark: "Dark",
@@ -171,7 +177,7 @@ export const en = {
language: {
title: "Language",
label: "Display language",
- description: "Choose the language used by Studio.",
+ description: "The language used by Unsloth.",
},
layout: {
title: "Layout",
@@ -182,25 +188,25 @@ export const en = {
},
chat: {
title: "Chat",
- description: "Manage your chat history stored on this device.",
+ description: "Manage chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
- "Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
+ "Artifacts mode collapses full HTML automatically. Turn on to also collapse fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
- "Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
+ "Let artifact previews load scripts, styles, fonts, media, and network resources from CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:
- "Download all chats and messages as a JSON file.",
+ "Download all chats and messages as JSON.",
exportAction: "Export",
exportingAction: "Exporting...",
exportConversations: "Export Recents and Projects",
exportConversationsDescription:
- "Download Recents only, or Recents plus project chats, as Raw JSONL, CSV, or ShareGPT JSONL, combined or one file per chat.",
+ "Download Recents or Recents plus project chats as Raw JSONL, CSV, or ShareGPT JSONL, combined or per chat.",
exportConversationsAction: "Export",
exportScopeRecents: "Recents",
exportScopeAll: "Recents + Projects",
@@ -208,14 +214,14 @@ export const en = {
exportPerChatSuffix: "(per chat)",
importChats: "Import chats",
importChatsDescription:
- "Add conversations from a JSONL, NDJSON, or CSV export to Recents.",
+ "Import a JSONL, NDJSON, or CSV export into Recents.",
importChatsAction: "Import",
importNoConversations: "No conversations found in file.",
importedOneChat: "Imported 1 conversation to Recents.",
importedChatCount: "Imported {count} conversations to Recents.",
importFailed: "Import failed.",
clearHistory: "Clear chat history",
- clearHistoryDescription: "Delete local chat history from this device.",
+ clearHistoryDescription: "Delete chat history from this device.",
clearAction: "Clear",
clearAllChats: "Clear all chats",
clearAllChatsDescription: "Permanently delete every chat on this device.",
@@ -228,7 +234,7 @@ export const en = {
clearOneChatTitle: "Clear 1 chat?",
clearChatsTitle: "Clear {count} chats?",
clearChatsConfirmDescription:
- "This permanently deletes every chat and message stored on this device. This cannot be undone.",
+ "Permanently deletes every chat on this device. This cannot be undone.",
clearingAction: "Clearing...",
clearOneChatAction: "Clear 1 chat",
clearChatCountAction: "Clear {count} chats",
@@ -251,12 +257,12 @@ export const en = {
},
connections: {
title: "Connections",
- description: "Manage providers and external service connections.",
+ description: "Manage providers and external connections.",
},
apiKeys: {
title: "API",
description:
- "Access Unsloth programmatically via the OpenAI-compatible API.",
+ "Access Unsloth via the OpenAI-compatible API.",
readDocs: "Read the API docs",
noAccess: "No API access yet.",
newBadge: "New",
@@ -296,62 +302,71 @@ export const en = {
revokeToken: "Revoke token",
revokeTitle: 'Revoke access token "{name}"?',
revokeDescription:
- "Applications using this token will immediately lose access. This cannot be undone.",
+ "Apps using this token immediately lose access. This cannot be undone.",
revokeAction: 'Revoke "{name}"',
revoking: "Revoking...",
},
about: {
title: "About",
description:
- "Documentation, release notes, feedback, and Studio build info.",
- studioVersion: "Studio Version",
+ "Docs, release notes, feedback, and build info.",
+ studioVersion: "Unsloth Version",
packageVersion: "Package Version",
- updates: "Updates",
+ updates: "Update",
help: "Help",
documentation: "Documentation",
releaseNotes: "Release notes",
whatsNew: "What's new",
feedback: "Feedback",
reportIssue: "Report an issue",
+ license: {
+ sectionTitle: "License",
+ studioLabel: "Unsloth Studio",
+ studioLicense: "AGPL-3.0",
+ studioDescription:
+ "Open source under the GNU AGPL v3.0.",
+ libraryLabel: "Unsloth Core",
+ libraryLicense: "Apache-2.0",
+ libraryDescription: "Licensed under Apache 2.0.",
+ },
dangerZone: "Danger zone",
shutDownStudio: "Shut down Unsloth Studio",
shutDownStudioDescription:
- "Stops the Studio server process and ends your session.",
+ "Stops the Unsloth server and ends your session.",
shutDown: "Shut down",
update: {
title: "Update Unsloth Studio",
- openPowerShell: "Open PowerShell and run:",
- openTerminal: "Open Terminal and run:",
commandText: "{label} text",
copied: "Copied",
copyCommand: "Copy command",
commandCopied: "{label} copied",
copyNamedCommand: "Copy {label}",
- checkingInstall: "Checking how Studio was installed...",
+ checkingInstall: "Checking how Unsloth was installed...",
+ installIntro: "To install or update Unsloth:",
+ localUpdateHeading: "Local update",
+ installCommandUnix: "macOS/Linux install command",
+ installCommandWindows: "Windows install command",
localInstallDetected:
- "Source or local install detected. To avoid replacing it with PyPI, update from the checkout or source you originally installed from.",
- pullThenUpdate:
- "Pull latest changes from your Unsloth repo checkout, then update Studio locally:",
+ "Local install detected. Update from your original checkout to avoid replacing it with PyPI.",
+ pullThenUpdate: "Pull the latest changes, then run the local installer:",
gitPullCommand: "git pull command",
- localUpdateCommand: "local update command",
- localInstallerFallback:
- "If the Studio update command is unavailable, run the local installer from that checkout:",
localInstallerCommand: "local installer command",
sourceInstallDetected:
- "This looks like a source or VCS package install. Reinstall from the original local path or Git URL you used.",
+ "Source or VCS package install detected. Reinstall from the original local path or Git URL.",
repoCheckoutFallback:
- "If you still have the Unsloth repo checkout, run the local installer from that checkout:",
- restartAfterUpdate:
- "Restart Studio after updating for changes to take effect.",
+ "If you still have the repo checkout, run the local installer from it:",
+ restartAfterUpdate: "Restart Unsloth after updating.",
+ desktopManaged:
+ "The desktop app keeps its bundled backend updated and will prompt when a new version is available.",
unknownInstall:
- "Studio could not detect how it was installed. Check how you installed Studio first, then choose the matching update path.",
- curlOrPypi: "For curl or PyPI installs, run:",
- updateCommand: "update command",
+ "Could not detect how Unsloth was installed. For installer or PyPI installs, use the commands above.",
localCheckout:
- "For local checkout installs, update from that checkout instead and use the local update command:",
- fallbackInstruction:
- "If that fails or unsloth studio update is unavailable, run:",
- fallbackCommand: "fallback command",
+ "For local checkout installs, run the local installer from that checkout:",
+ docs: "Install docs:",
+ docsInstall: "Installation",
+ docsUpdating: "Updating",
+ docsMac: "Mac",
+ docsWindows: "Windows",
},
},
},
diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts
index e7d3babfe9..5173f9fa74 100644
--- a/studio/frontend/src/i18n/locales/zh-CN.ts
+++ b/studio/frontend/src/i18n/locales/zh-CN.ts
@@ -93,7 +93,7 @@ export const zhCN = {
chat: "聊天",
connections: "连接",
apiKeys: "API",
- about: "帮助",
+ about: "关于",
},
general: {
title: "通用",
@@ -106,6 +106,12 @@ export const zhCN = {
chatDefaults: "聊天默认设置",
autoTitleNewChats: "自动为新聊天命名",
autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",
+ notifications: {
+ sectionTitle: "通知",
+ showLlamaUpdates: "llama.cpp 更新通知",
+ showLlamaUpdatesDescription:
+ "有新的 llama.cpp 构建时提醒。如果只用于训练可以关闭。",
+ },
gettingStarted: "入门",
startOnboarding: "开始引导",
startOnboardingDescription: "重新打开设置向导,不会更改你的账号。",
@@ -124,13 +130,13 @@ export const zhCN = {
action: "重置偏好设置",
confirmTitle: "重置所有本地偏好设置?",
confirmDescription:
- "这会清除仅保存在本地的偏好设置,然后重新加载 Studio。聊天、API 访问权限和数据库中的设置不会受到影响。",
+ "这会清除仅保存在本地的偏好设置,然后重新加载 Unsloth。聊天、API 访问权限和数据库中的设置不会受到影响。",
confirmAction: "重置并重新加载",
},
},
profile: {
title: "个人资料",
- description: "更新你在 Studio 中显示的个人资料。",
+ description: "更新你在 Unsloth 中显示的个人资料。",
changePicture: "更换头像",
displayName: "显示名称",
nameSaved: "个人资料名称已保存",
@@ -150,7 +156,7 @@ export const zhCN = {
language: {
title: "语言",
label: "显示语言",
- description: "选择 Studio 使用的语言。",
+ description: "选择 Unsloth 使用的语言。",
},
theme: {
title: "主题",
@@ -274,9 +280,9 @@ export const zhCN = {
revoking: "撤销中...",
},
about: {
- title: "帮助",
- description: "文档、发布说明、反馈和 Studio 构建信息。",
- studioVersion: "Studio 版本",
+ title: "关于",
+ description: "文档、发布说明、反馈和 Unsloth 构建信息。",
+ studioVersion: "Unsloth 版本",
packageVersion: "包版本",
updates: "更新",
help: "帮助",
@@ -285,43 +291,52 @@ export const zhCN = {
whatsNew: "最新内容",
feedback: "反馈",
reportIssue: "报告问题",
+ license: {
+ sectionTitle: "许可证",
+ studioLabel: "Unsloth Studio",
+ studioLicense: "AGPL-3.0",
+ studioDescription: "基于 GNU AGPL v3.0 开源。",
+ libraryLabel: "Unsloth Core",
+ libraryLicense: "Apache-2.0",
+ libraryDescription: "基于 Apache License 2.0 许可。",
+ },
dangerZone: "危险区域",
shutDownStudio: "关闭 Unsloth Studio",
- shutDownStudioDescription: "停止 Studio 服务进程并结束你的会话。",
+ shutDownStudioDescription: "停止 Unsloth 服务进程并结束你的会话。",
shutDown: "关闭",
update: {
title: "更新 Unsloth Studio",
- openPowerShell: "打开 PowerShell 并运行:",
- openTerminal: "打开终端并运行:",
commandText: "{label} 文本",
copied: "已复制",
copyCommand: "复制命令",
commandCopied: "{label} 已复制",
copyNamedCommand: "复制 {label}",
- checkingInstall: "正在检查 Studio 的安装方式...",
+ checkingInstall: "正在检查 Unsloth 的安装方式...",
+ installIntro: "安装或更新 Unsloth:",
+ localUpdateHeading: "本地更新",
+ installCommandUnix: "macOS/Linux 安装命令",
+ installCommandWindows: "Windows 安装命令",
localInstallDetected:
- "检测到源码或本地安装。为避免替换为 PyPI 版本,请从最初安装时使用的 checkout 或源码位置更新。",
- pullThenUpdate:
- "从你的 Unsloth 仓库 checkout 拉取最新变更,然后本地更新 Studio:",
+ "检测到本地安装。请从最初的 checkout 更新,以免被 PyPI 版本替换。",
+ pullThenUpdate: "拉取最新变更,然后运行本地安装器:",
gitPullCommand: "git pull 命令",
- localUpdateCommand: "本地更新命令",
- localInstallerFallback:
- "如果 Studio 更新命令不可用,请从该 checkout 运行本地安装器:",
localInstallerCommand: "本地安装器命令",
sourceInstallDetected:
"这看起来是源码或 VCS 包安装。请从最初使用的本地路径或 Git URL 重新安装。",
repoCheckoutFallback:
"如果你仍保留 Unsloth 仓库 checkout,请从该 checkout 运行本地安装器:",
- restartAfterUpdate: "更新后重启 Studio,使变更生效。",
+ restartAfterUpdate: "更新后请重启 Unsloth。",
+ desktopManaged:
+ "桌面应用会自动更新其内置后端,有新版本时会提示。",
unknownInstall:
- "Studio 无法检测安装方式。请先确认你如何安装 Studio,然后选择匹配的更新方式。",
- curlOrPypi: "对于 curl 或 PyPI 安装,请运行:",
- updateCommand: "更新命令",
+ "Unsloth 无法检测安装方式。如果你使用一键安装器或 PyPI 安装,请使用上面的命令。",
localCheckout:
- "对于本地 checkout 安装,请改为从该 checkout 更新并使用本地更新命令:",
- fallbackInstruction:
- "如果失败,或 unsloth studio update 不可用,请运行:",
- fallbackCommand: "备用命令",
+ "对于本地 checkout 安装,请改为从该 checkout 运行本地安装器:",
+ docs: "安装文档:",
+ docsInstall: "安装",
+ docsUpdating: "更新",
+ docsMac: "Mac",
+ docsWindows: "Windows",
},
},
},
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css
index 0d3b1698e1..9e394a31e7 100644
--- a/studio/frontend/src/index.css
+++ b/studio/frontend/src/index.css
@@ -552,6 +552,7 @@
.sidebar-nav-btn[data-state="open"],
.group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
+ .group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
background-color: var(--nav-surface-hover) !important;
@@ -562,6 +563,7 @@
.dark .sidebar-nav-btn[data-state="open"],
.dark .group\/project-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/project-chat-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
+ .dark .group\/projects-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/recent-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn,
.dark .group\/run-item:hover:not(:has(.sidebar-row-action:hover)) .sidebar-nav-btn {
color: #fff !important;
@@ -957,6 +959,11 @@
border-radius: 14px !important;
}
+ /* Account menu: a touch rounder than standard list menus. */
+ [data-slot="dropdown-menu-content"].app-user-menu.menu-soft-surface-up {
+ border-radius: 18px !important;
+ }
+
/* Every dropdown/menu/select/popover: borderless, chatbox shadow in light,
none in dark. !important so it also overrides the bespoke menu shadows. */
[data-slot="dropdown-menu-content"],
@@ -1078,13 +1085,16 @@
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])[data-pill-label]:hover::after {
content: attr(data-pill-label);
- @apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-[9px] bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
+ /* Always one nowrap line, so always a full pill. */
+ @apply pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-50 -translate-x-1/2 rounded-full bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug whitespace-nowrap text-white shadow-md;
}
/* Compact caret pills (RAG, MCP) open their menu on click instead of
- toggling off, so keep the icon and skip the X swap. */
+ toggling off, so keep the icon and skip the X swap. No data-active
+ requirement: the off-switch glyph rules below hide the icon on hover
+ even for inactive pills, which left a blank slot in compact mode. */
[data-pill-compact="true"]
- .composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret)[data-active="true"]:hover
+ .composer-pill-btn:not([data-keep-label]):has(.composer-pill-caret):hover
.composer-pill-glyph
> :not(.composer-pill-x) {
opacity: 1;
@@ -1512,6 +1522,20 @@
}
}
+ /* Indeterminate loading bar: a 1/3-width segment sweeping the track. */
+ .loading-bar-slide {
+ animation: loading-bar-slide 1.3s ease-in-out infinite;
+ }
+
+ @keyframes loading-bar-slide {
+ from {
+ transform: translateX(-110%);
+ }
+ to {
+ transform: translateX(420%);
+ }
+ }
+
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
@@ -1750,10 +1774,10 @@
margin: 0 !important;
}
-/* Composer shadow on the dark background color so toasts
- do not merge into card-colored surfaces behind them. */
+/* Dark toasts share the chatbox surface color (.chat-composer-surface
+ uses var(--card) in dark); the composer shadow lifts them off the page. */
.dark [data-sonner-toast][data-styled='true'] {
- background-color: var(--background) !important;
+ background-color: var(--card) !important;
box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16) !important;
}
@@ -2099,6 +2123,11 @@
animation-iteration-count: infinite !important;
}
+ .loading-bar-slide {
+ animation-duration: 1.3s !important;
+ animation-iteration-count: infinite !important;
+ }
+
/* Keep the plus/x morph animating under reduced motion (small rotation,
like the spinners above). */
.unsloth-composer-plus svg {
diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py
index 2b42a7a058..bd8c90dc9c 100644
--- a/studio/install_llama_prebuilt.py
+++ b/studio/install_llama_prebuilt.py
@@ -856,6 +856,16 @@ def format_byte_count(num_bytes: float) -> str:
return f"{num_bytes:.1f} B"
+def _progress_percent_step() -> int:
+ """Non-tty milestone granularity. The in-app updater sets
+ UNSLOTH_PROGRESS_PERCENT_STEP=5 to stream finer progress lines."""
+ try:
+ step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
+ except ValueError:
+ return 25
+ return min(max(step, 1), 50)
+
+
class DownloadProgress:
def __init__(self, label: str, total_bytes: int | None) -> None:
self.label = label
@@ -868,6 +878,7 @@ class DownloadProgress:
)
self.is_tty = term_ok and self.stream.isatty()
self.completed = False
+ self.milestone_step = _progress_percent_step()
self.last_milestone_percent = -1
self.last_milestone_bytes = 0
self.has_rendered_tty_progress = False
@@ -918,7 +929,8 @@ class DownloadProgress:
should_emit = False
if self.total_bytes is not None:
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
- milestone_percent = min((percent // 25) * 25, 100)
+ step = self.milestone_step
+ milestone_percent = min((percent // step) * step, 100)
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
self.last_milestone_percent = milestone_percent
should_emit = True