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 01/50] 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 : ( -
+ {updateProgress != null && updateProgress > 0 ? ( +
+ ) : ( + // No percent yet (resolving the release): sweep until the + // first download progress arrives. +
+ )} +
+ ) : ( +
+ + +
+ )}
) : 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({
+