From b2b1dcd6ad72addde9a4324d15a2966fd0df6437 Mon Sep 17 00:00:00 2001
From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
Date: Thu, 11 Jun 2026 09:27:34 -0700
Subject: [PATCH] Studio: llama.cpp update banner redesign, About tab license
info, UI polish (#6196)
* Studio: llama.cpp update banner redesign, About tab license info, inline system prompt editing, naming cleanup
- Redesign the llama.cpp update banner to match the chat composer surface
(borderless rounded card, composer shadow, Hellix Medium title), rename
actions to Update and add a 15 minute Remind me later snooze
- Keep the banner up until the user explicitly acts on it; drop the
outside click dismissal
- Add a Settings > General > Notifications toggle to disable the banner
for training-only setups (on by default)
- Rename the Help settings tab to About and add a License section
(Unsloth Studio AGPL-3.0, Unsloth Core Apache-2.0) linking to the
license files in this repo
- Make the run settings system prompt box an inline editable textarea;
the popup editor opens when the prompt overflows the box
- Pointer cursor on the preset dropdown chevron
- Dark mode toasts use the chat composer surface color
- Replace standalone Studio with Unsloth in user facing strings; keep
Unsloth Studio, LM Studio, Fine-tuning Studio, Recipe Studio and CLI
commands unchanged
* Studio: open the system prompt popup on box click, balance banner padding
- The system prompt box opens the Edit System Prompt dialog on click,
matching the pencil action
- Slightly more bottom padding on the llama.cpp update banner so the
spacing reads even next to the action pills
* Studio: replace unsloth studio update with the installer commands in update guidance
- The unsloth studio update command no longer works, so the About tab
update section now shows the one-line installer (curl or irm) for
PyPI and unknown installs, and git pull plus the local installer for
checkouts
- Add a short note that unsloth studio update is no longer supported
- Link the Installation, Updating and Windows install docs pages
- The package update banner now copies the platform installer command
instead of unsloth studio update
* Studio: rounder account menu, inline system prompt box with popup from the label
- Account menu corners go from 14px to 18px via a specific override,
since list menus pin border-radius globally
- llama.cpp banner bottom padding 22px
- System prompt is an inline editable textarea again; clicking the
System Prompt label opens the popup editor, and an overflowing
prompt opens it on box click
* Studio: show the standard install commands in the About update section
- Both one-line install commands (MacOS/Linux/WSL and Windows
PowerShell) are always shown, labeled like the docs, since running
them again updates an existing install
- Drop the unsloth studio update deprecation note
- Add the Mac install guide to the docs links
* Studio: clearer platform toggle and layout in the About update section
- Section heading is Update
- Platform picker is a pair of pill buttons, MacOS / Linux and Windows,
and only the selected platform's install command is shown
- Intro reads: To install or update Unsloth
- Local update heading separates checkout guidance from the standard
install command
* Studio: report GitHub branch instead of dev for source checkouts
A source checkout not on an exact release tag now shows
GitHub (e.g. GitHub main) as the Studio version in About.
Detached or unusual HEADs still fall back to dev.
* Studio: tighten the About update section copy and toggle styling
- Platform toggle buttons are borderless pills
- Shorter local update wording and restart note
- Docs links read Mac and Windows
* Studio: tighten line spacing in the sidebar account button
* Studio: fix vanishing compact MCP icon on hover, single line pill tooltips
- Compact caret pills (MCP, RAG) keep their icon on hover for inactive
pills too; the off switch hover rules hid the icon while compact mode
hid the X, leaving an empty slot
- Compact icon tooltips and single line compact tooltips render as full
pills; wrapped tooltips keep the 9px corners. TooltipContent measures
line count in a ref callback since Radix mounts portal content
without re-rendering the wrapper
- 1px gap between the name and Unsloth lines in the sidebar account
button
* Studio: Projects hover plus button, align recents with the label
- Hovering the Projects nav item reveals a plus button that opens the
New project dialog, with the same circular hover treatment as the
chat row actions
- Recent chat titles start at the same x as the Recents label
- The system prompt overflow lock only engages for a non-empty prompt
with a laid-out box, so a mis-measure cannot turn clicks into the
popup
* Clip system prompt overflow inside the rounded box
Wrap the inline system prompt textarea in a rounded overflow-hidden
surface so scrolled text and the scrollbar stay inside the box. The
focus ring moves to the wrapper via focus-within.
* Add updating progress bar to llama banner and shorten settings copy
While an update is applying, the banner action row becomes an
indeterminate progress bar that keeps animating under reduced motion,
matching the other loading indicators. Settings descriptions across
General, Profile, Appearance, Chat, Connections, API, and About are
trimmed without losing meaning.
* Address review: desktop update note, server platform detection, zh-CN keys
The About tab no longer shows terminal install commands in the desktop
app, where the bundled backend updates through the built-in updater;
it shows a short note and the docs links instead.
fetchDeviceType now sends the auth token to /api/health, which only
reports the server platform to authed callers, and caches only a
server-reported value. Copied install commands then match the host
platform rather than the browser when they differ (WSL, SSH).
zh-CN gains translations for the new notification and license keys,
the renamed About tab title, and the desktop update note.
* Real download progress for llama.cpp updates, prompt and sidebar polish
The update worker now streams the installer output and parses its
download percent lines into job progress, exposed via the update-status
API. The installer emits finer non-tty milestones when
UNSLOTH_PROGRESS_PERCENT_STEP is set; the worker requests 5 percent
steps. The banner renders a determinate bar from the reported fraction
and falls back to the sweep until the first percent arrives.
Also removes the focus ring on the inline system prompt box and
slightly shrinks the Projects hover plus icon.
---
studio/backend/routes/llama.py | 1 +
studio/backend/tests/test_llama_cpp_update.py | 130 +++++----
studio/backend/utils/llama_cpp_update.py | 53 +++-
studio/backend/utils/studio_version.py | 35 ++-
.../frontend/src/components/app-sidebar.tsx | 29 +-
.../src/components/llama-update-banner.tsx | 117 ++++----
.../src/components/tauri/startup-screen.tsx | 2 +-
studio/frontend/src/components/ui/tooltip.tsx | 18 ++
.../src/components/web/update-banner.tsx | 16 +-
studio/frontend/src/config/env.ts | 20 +-
studio/frontend/src/features/auth/api.ts | 2 +-
.../src/features/auth/tauri-auto-auth.ts | 2 +-
.../studio-web-speech-dictation-adapter.ts | 4 +-
.../features/chat/chat-providers-dialog.tsx | 2 +-
.../src/features/chat/chat-settings-sheet.tsx | 61 +++-
.../frontend/src/features/chat/tour/steps.tsx | 4 +-
.../chat/utils/chat-history-storage.ts | 2 +-
.../src/features/hub/download-manager/api.ts | 6 +-
.../download-manager/download-api-adapter.ts | 2 +-
.../executions/execution-data-tab.tsx | 2 +-
.../executions/execution-overview-tab.tsx | 2 +-
.../runtime/execution-progress-island.tsx | 2 +-
.../dialogs/seed/seed-dialog.tsx | 6 +-
.../components/update-studio-instructions.tsx | 265 +++++++++++-------
.../src/features/settings/tabs/about-tab.tsx | 33 ++-
.../features/settings/tabs/general-tab.tsx | 21 ++
.../src/features/studio/tour/steps/nav.tsx | 2 +-
.../src/hooks/use-llama-update-check.ts | 33 ++-
.../src/hooks/use-llama-update-pref.ts | 49 ++++
.../frontend/src/hooks/use-tauri-backend.ts | 22 +-
studio/frontend/src/i18n/locales/en.ts | 107 ++++---
studio/frontend/src/i18n/locales/zh-CN.ts | 65 +++--
studio/frontend/src/index.css | 41 ++-
studio/install_llama_prebuilt.py | 14 +-
34 files changed, 831 insertions(+), 339 deletions(-)
create mode 100644 studio/frontend/src/hooks/use-llama-update-pref.ts
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