diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e747605322..6f1a7f14b1 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -353,7 +353,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf # ── Node.js ── - name: Setup Node.js @@ -406,9 +406,24 @@ jobs: if (config.bundle?.linux?.rpm) { throw new Error('bundle.linux.rpm must not be configured'); } + if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) { + throw new Error('Linux AppImage bundleMediaFramework must stay false'); + } const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8'); const lines = workflow.split(/\r?\n/); + const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install')); + const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-'); + if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) { + throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package'); + } + if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) { + throw new Error('Desktop Linux release must install libappindicator3-dev'); + } + const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download')); + if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) { + throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2'); + } const releaseBodies = []; for (let i = 0; i < lines.length; i += 1) { const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/); @@ -438,6 +453,12 @@ jobs: if (/\brpm\b|\.rpm/i.test(body)) { throw new Error('Desktop release body must not advertise RPM packages'); } + if (/AppImage.*universal|universal.*AppImage/i.test(body)) { + throw new Error('Desktop release body must not advertise AppImage as universal'); + } + if (!/AppImage.*experimental/i.test(body)) { + throw new Error('Desktop release body must mark AppImage as experimental'); + } } JS @@ -562,6 +583,19 @@ jobs: Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH" trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run" + # ── Linux: pin AppImage packaging toolchain ── + - name: Pin linuxdeploy for AppImage + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + set -euo pipefail + tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri" + mkdir -p "$tools_dir" + curl -fsSL \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage" \ + -o "$tools_dir/linuxdeploy-x86_64.AppImage" + chmod +x "$tools_dir/linuxdeploy-x86_64.AppImage" + # ── Linux: build + sign + upload ── - name: Build Linux app if: matrix.platform == 'ubuntu-22.04' @@ -570,6 +604,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache with: projectPath: studio tauriScript: npx --prefix . tauri @@ -580,9 +615,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -611,9 +647,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -643,9 +680,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} diff --git a/.github/workflows/studio-tauri-smoke.yml b/.github/workflows/studio-tauri-smoke.yml index 1156c264ae..018857de68 100644 --- a/.github/workflows/studio-tauri-smoke.yml +++ b/.github/workflows/studio-tauri-smoke.yml @@ -47,7 +47,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + libwebkit2gtk-4.1-dev libappindicator3-dev \ librsvg2-dev libxdo-dev libssl-dev patchelf - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/README.md b/README.md index fcb0ee8dc0..9162d29b1c 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,15 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex ``` +Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`): +```bash +UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local +``` +```powershell +$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local +``` +It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force. + Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`. #### Uninstall diff --git a/build.sh b/build.sh index 286664b5e8..dc272f0de1 100644 --- a/build.sh +++ b/build.sh @@ -35,10 +35,19 @@ _restore_gitignores() { } trap _restore_gitignores EXIT +# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we +# thread it as `--registry ` into the installs (overrides frontend/.npmrc's pinned +# registry for both bun and npm; min-release-age / save-exact stay in force). Empty +# array (the default) expands to nothing under `set -u`. +_NPM_REGISTRY_ARGS=() +if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then + _NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY") +fi + # Use bun for install if available (faster), fall back to npm. _install_ok=false if command -v bun &>/dev/null; then - if bun install; then + if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then _install_ok=true else echo "⚠ bun install failed, falling back to npm" @@ -46,8 +55,10 @@ if command -v bun &>/dev/null; then fi fi if [ "$_install_ok" != "true" ]; then - if ! npm install; then + if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then echo "❌ ERROR: package install failed" >&2 + echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2 + echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2 exit 1 fi fi diff --git a/install.sh b/install.sh index b3eaa61003..548e6f702a 100755 --- a/install.sh +++ b/install.sh @@ -447,8 +447,12 @@ _on_install_exit() { if [ "$_status" -ne 0 ]; then _restore_studio_venv_replacement fi + [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true exit "$_status" } +# Empty so an inherited value can never reach the trap's rm; only a temp dir +# this script creates below (Apple Silicon, spaced path) is ever removed. +_UV_OVERRIDE_TMPDIR="" trap _on_install_exit EXIT # ── Helper: download a URL to a file (supports curl and wget) ── @@ -1427,6 +1431,25 @@ fi if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then _OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt" if [ -f "$_OVERRIDES_FILE" ]; then + # uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace + # truncates it and aborts every later uv call (issue #6503). Hand uv a copy. + case "$_OVERRIDES_FILE" in + *[[:space:]]*) + _UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR="" + case "$_UV_OVERRIDE_TMPDIR" in + "") ;; + *[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;; + *) + if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then + _OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" + else + rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true + _UV_OVERRIDE_TMPDIR="" + fi + ;; + esac + ;; + esac export UV_OVERRIDE="$_OVERRIDES_FILE" fi fi diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index a13a75a72e..6f55daee39 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -3543,9 +3543,12 @@ class UnslothTrainer: # ── Safety net: check if all samples were filtered out ── # train_on_responses_only masks non-response tokens with -100; - # if max_seq_length is too short the response is truncated away, - # every sample becomes all -100, and Unsloth drops them, leaving - # 0 usable samples. Skip this len()-based check for streaming. + # a row becomes all -100 (and Unsloth drops it) when the response + # template is not found in the formatted text. That is usually a + # dataset/template mismatch (already-formatted data, or 'Train on + # completions' applied to data that doesn't match the model's chat + # template), and only sometimes max_seq_length truncating the + # response away. Skip this len()-based check for streaming. if detect_streaming_dataset(self.trainer.train_dataset): logger.info("Skipping post-filter length check for streaming dataset\n") else: @@ -3560,13 +3563,18 @@ class UnslothTrainer: if filtered_len == 0 or drop_pct > 30: max_seq = training_args.get("max_seq_length", 2048) error_msg = ( - f"{dropped}/{original_len} samples ({drop_pct}%) " - f"were dropped after applying 'train on responses " - f"only' — only {filtered_len} remain. This usually " - f"means max_seq_length ({max_seq}) is too short " - f"and the response portion is being truncated " - f"away. Try increasing max_seq_length (e.g. 8192) " - f"or disabling 'Train on completions'." + f"{dropped}/{original_len} samples ({drop_pct}%) were " + f"dropped after applying 'Train on completions': after " + f"masking, those rows had no trainable response tokens " + f"left. The usual cause is that this model's response " + f"template was not found in the formatted samples, so " + f"every token was masked out. That typically means the " + f"dataset is already formatted, or its structure does " + f"not match the model's chat template, so 'Train on " + f"completions' should be turned off for this dataset. " + f"Less commonly, a max_seq_length ({max_seq}) shorter " + f"than the prompt can truncate the response away; only " + f"raise it if your samples are actually longer than that." ) logger.error(error_msg) self._update_progress(error = error_msg, is_training = False) diff --git a/studio/backend/hub/services/models/deletion.py b/studio/backend/hub/services/models/deletion.py index ecc9f8426d..916bb9d4f8 100644 --- a/studio/backend/hub/services/models/deletion.py +++ b/studio/backend/hub/services/models/deletion.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import errno from pathlib import Path from typing import Optional @@ -15,7 +16,7 @@ from loggers import get_logger from hub.utils import download_manifest from hub.utils import download_registry from hub.utils import inventory_scan as hf_cache_scan -from hub.utils.gguf import extract_quant_label +from hub.utils.gguf import extract_quant_label, extract_quant_token from hub.utils.hf_cache_state import ( INCOMPLETE_SUFFIX, purge_partial_repo, @@ -106,6 +107,52 @@ def _has_remaining_main_gguf(target_repo) -> bool: ) +def _remove_empty_variant_dirs(target_repos: list, variant: str) -> tuple[int, list[str]]: + """Remove now-empty ``snapshots///`` folders for *variant* (the + quant label names the folder); only empty dirs go, so siblings are safe. + Returns (count removed, removal failures other than a concurrent refill).""" + variant_key = (extract_quant_token(variant) or variant).lower() + removed = 0 + failures: list[str] = [] + for target_repo in target_repos: + repo_path = getattr(target_repo, "repo_path", None) + if not repo_path: + continue + snapshots = Path(repo_path) / "snapshots" + if not snapshots.is_dir(): + continue + try: + snap_dirs = [s for s in snapshots.iterdir() if s.is_dir() and not s.is_symlink()] + except OSError: + continue + for snap in snap_dirs: + try: + subs = list(snap.iterdir()) + except OSError: + continue + for sub in subs: + try: + if sub.is_symlink() or not sub.is_dir(): + continue + folder_quant = extract_quant_token(sub.name) + matches = ( + folder_quant is not None and folder_quant.lower() == variant_key + ) or sub.name.lower() == variant.lower() + if not matches or any(sub.iterdir()): + continue + except OSError: + continue + try: + sub.rmdir() + removed += 1 + except OSError as e: + # A concurrent download refilling the dir (ENOTEMPTY) is not a + # failure; a read-only cache or locked dir is, so surface it. + if e.errno != errno.ENOTEMPTY: + failures.append(f"{sub.name}: {e}") + return removed, failures + + def _delete_gguf_variant_from_repos( repo_id: str, variant: str, @@ -206,11 +253,23 @@ def _delete_gguf_variant_from_repos( ) state_purged = download_manifest.purge_state("model", repo_id, variant) + # Reclaim the empty quant folder so it stops 404ing on delete. + removed_dirs, dir_failures = _remove_empty_variant_dirs(target_repos, variant) + if dir_failures: + raise HTTPException( + status_code = 409, + detail = ( + f"Couldn't fully delete {variant} for {repo_id}: " + f"{len(dir_failures)} folder(s) could not be removed " + "(read-only cache or in use). Try again." + ), + ) if ( removed_snapshots == 0 and deleted_blobs == 0 and incomplete_result.deleted == 0 and not state_purged + and removed_dirs == 0 ): raise HTTPException( status_code = 404, diff --git a/studio/backend/hub/services/models/gguf_variants.py b/studio/backend/hub/services/models/gguf_variants.py index 74c3ad6ce2..0c0e6f9254 100644 --- a/studio/backend/hub/services/models/gguf_variants.py +++ b/studio/backend/hub/services/models/gguf_variants.py @@ -27,6 +27,7 @@ from hub.utils.gguf import ( extract_quant_label, iter_hf_cache_snapshots, is_big_endian_gguf_path, + list_empty_gguf_variant_dirs, list_gguf_variants, list_gguf_variants_from_hf_cache, list_local_gguf_variants, @@ -334,6 +335,32 @@ def delete_variant_incomplete_blobs_result( return VariantIncompleteDeleteResult(deleted = deleted, unresolved = False) +def _mark_empty_dir_cleanables( + repo_id: str, response: GgufVariantsResponse +) -> GgufVariantsResponse: + """Surface empty leftover ``/`` folders (interrupted downloads) as + partial so the UI can delete them -- on local/offline paths too, not just a + remote listing. A listed quant is flipped to partial; an unlisted one is + appended as a zero-byte cleanable entry.""" + try: + empty_labels = list_empty_gguf_variant_dirs(repo_id) + except Exception as e: + logger.warning(f"Failed to scan empty GGUF variant folders for {repo_id}: {e}") + return response + if not empty_labels: + return response + empty_by_key = {label.lower(): label for label in empty_labels} + variants = list(response.variants) + listed = {v.quant.lower() for v in variants} + for i, v in enumerate(variants): + if v.quant.lower() in empty_by_key and not v.downloaded and not v.partial: + variants[i] = v.model_copy(update = {"partial": True}) + for key, label in sorted(empty_by_key.items()): + if key not in listed: + variants.append(GgufVariantDetail(filename = f"{label}.gguf", quant = label, partial = True)) + return response.model_copy(update = {"variants": variants}) + + async def get_gguf_variants_response( repo_id: str, prefer_local_cache: bool = False, @@ -653,8 +680,28 @@ async def get_gguf_variants_response( default_variant = default_variant, ) + def _compute_with_cleanables() -> GgufVariantsResponse: + skip = is_local_path(repo_id) or not _is_valid_repo_id(repo_id) + try: + response = _compute() + except Exception: + # Offline / metadata fetch failed with only an empty leftover + # / folder cached: still surface it so the UI can delete it, + # otherwise re-raise the original error. + if skip: + raise + enriched = _mark_empty_dir_cleanables( + repo_id, GgufVariantsResponse(repo_id = repo_id, variants = []) + ) + if enriched.variants: + return enriched + raise + if skip: + return response + return _mark_empty_dir_cleanables(repo_id, response) + try: - return await asyncio.to_thread(_compute) + return await asyncio.to_thread(_compute_with_cleanables) except HTTPException: raise except Exception as e: diff --git a/studio/backend/hub/tests/test_empty_variant_folder.py b/studio/backend/hub/tests/test_empty_variant_folder.py new file mode 100644 index 0000000000..33bf6c6819 --- /dev/null +++ b/studio/backend/hub/tests/test_empty_variant_folder.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Cleanup of empty leftover quant folders from interrupted split downloads.""" + +import errno +from pathlib import Path +from types import SimpleNamespace + +from hub.schemas.inventory import GgufVariantDetail, GgufVariantsResponse +from hub.services.models import deletion, gguf_variants +from hub.utils import gguf + + +def _make_snapshot(root: Path) -> Path: + snap = root / "snapshots" / "rev0" + (snap / "UD-IQ1_M").mkdir(parents = True) + (snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00001-of-00002.gguf").write_bytes(b"x") + (snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00002-of-00002.gguf").write_bytes(b"y") + (snap / "UD-IQ1_S").mkdir(parents = True) # empty leftover + return snap + + +def test_list_empty_gguf_variant_dirs_finds_empty_leftover(tmp_path, monkeypatch): + snap = _make_snapshot(tmp_path) + monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap])) + assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == {"UD-IQ1_S"} + + +def test_list_empty_excludes_quant_with_files_in_another_snapshot(tmp_path, monkeypatch): + snap1 = tmp_path / "s1" / "snapshots" / "rev" + (snap1 / "UD-IQ1_S").mkdir(parents = True) # empty here + snap2 = tmp_path / "s2" / "snapshots" / "rev" + (snap2 / "UD-IQ1_S").mkdir(parents = True) + (snap2 / "UD-IQ1_S" / "m-UD-IQ1_S-00001-of-00001.gguf").write_bytes(b"z") # has shards + monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap1, snap2])) + assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set() + + +def test_list_empty_ignores_non_quant_dirs(tmp_path, monkeypatch): + snap = tmp_path / "snapshots" / "rev" + (snap / "not-a-quant").mkdir(parents = True) # empty but not a quant label + monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap])) + assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set() + + +def test_remove_empty_variant_dirs_removes_only_empty_match(tmp_path): + snap = _make_snapshot(tmp_path) + repo = SimpleNamespace(repo_path = str(tmp_path)) + removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S") + assert removed == 1 + assert failures == [] + assert not (snap / "UD-IQ1_S").exists() + assert (snap / "UD-IQ1_M").is_dir() + + +def test_remove_empty_variant_dirs_never_touches_populated_folder(tmp_path): + snap = _make_snapshot(tmp_path) + repo = SimpleNamespace(repo_path = str(tmp_path)) + removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_M") + assert removed == 0 + assert failures == [] + assert len(list((snap / "UD-IQ1_M").iterdir())) == 2 + + +def test_remove_empty_variant_dirs_surfaces_real_failure(tmp_path, monkeypatch): + _make_snapshot(tmp_path) + repo = SimpleNamespace(repo_path = str(tmp_path)) + + def _denied(self): + raise OSError(errno.EACCES, "permission denied") + + monkeypatch.setattr(Path, "rmdir", _denied) + removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S") + assert removed == 0 + assert len(failures) == 1 + + +def test_remove_empty_variant_dirs_ignores_concurrent_refill(tmp_path, monkeypatch): + _make_snapshot(tmp_path) + repo = SimpleNamespace(repo_path = str(tmp_path)) + + def _refilled(self): + raise OSError(errno.ENOTEMPTY, "directory not empty") + + monkeypatch.setattr(Path, "rmdir", _refilled) + removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S") + assert removed == 0 + assert failures == [] + + +def test_mark_empty_dir_cleanables_appends_unlisted(monkeypatch): + monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"}) + resp = GgufVariantsResponse( + repo_id = "org/Repo-GGUF", + variants = [GgufVariantDetail(filename = "m-UD-IQ1_M.gguf", quant = "UD-IQ1_M", downloaded = True)], + ) + out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp) + by_q = {v.quant: v for v in out.variants} + assert by_q["UD-IQ1_M"].downloaded is True + assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False + + +def test_mark_empty_dir_cleanables_flips_listed_variant(monkeypatch): + monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"}) + resp = GgufVariantsResponse( + repo_id = "org/Repo-GGUF", + variants = [GgufVariantDetail(filename = "m-UD-IQ1_S.gguf", quant = "UD-IQ1_S")], + ) + out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp) + assert len(out.variants) == 1 + assert out.variants[0].partial is True + + +def _force_compute_to_raise(monkeypatch): + # Drive _compute() down its remote path, fail metadata, and have both cache + # fallbacks miss so the original error re-raises. + def _boom(*a, **k): + raise RuntimeError("offline") + + monkeypatch.setattr(gguf_variants, "list_gguf_variants", _boom, raising = False) + monkeypatch.setattr( + gguf_variants, "list_gguf_variants_from_hf_cache", lambda repo_id: None, raising = False + ) + monkeypatch.setattr( + gguf_variants, "list_partial_gguf_variants_from_state", lambda repo_id: None, raising = False + ) + + +def test_get_variants_surfaces_cleanable_when_metadata_fails(monkeypatch): + # Offline / model_info fails and only an empty leftover folder is cached: + # the cleanable must still be returned instead of the error propagating. + import asyncio + + _force_compute_to_raise(monkeypatch) + monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"}) + + resp = asyncio.run( + gguf_variants.get_gguf_variants_response( + "org/Repo-GGUF", prefer_local_cache = False, hf_token = None + ) + ) + by_q = {v.quant: v for v in resp.variants} + assert "UD-IQ1_S" in by_q + assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False + + +def test_get_variants_reraises_when_no_cleanable(monkeypatch): + # Offline with nothing cleanable: original error must propagate (as HTTP). + import asyncio + + from fastapi import HTTPException + + _force_compute_to_raise(monkeypatch) + monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: set()) + + try: + asyncio.run( + gguf_variants.get_gguf_variants_response( + "org/Repo-GGUF", prefer_local_cache = False, hf_token = None + ) + ) + raised = False + except (HTTPException, RuntimeError): + raised = True + assert raised diff --git a/studio/backend/hub/utils/gguf.py b/studio/backend/hub/utils/gguf.py index acd650bf42..2e3de125f1 100644 --- a/studio/backend/hub/utils/gguf.py +++ b/studio/backend/hub/utils/gguf.py @@ -276,6 +276,33 @@ def iter_hf_cache_snapshots(repo_id: str): yield from snapshots +def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]: + """Quant labels present only as an EMPTY snapshot ``/`` folder (an + interrupted split download); a quant with shards in any snapshot is excluded.""" + empty: dict[str, str] = {} + nonempty: set[str] = set() + for snapshot in iter_hf_cache_snapshots(repo_id): + try: + entries = list(snapshot.iterdir()) + except OSError: + continue + for sub in entries: + try: + if sub.is_symlink() or not sub.is_dir(): + continue + quant = extract_quant_token(sub.name) + if not quant: + continue + has_child = any(sub.iterdir()) + except OSError: + continue + if has_child: + nonempty.add(quant.lower()) + else: + empty.setdefault(quant.lower(), quant) + return {label for key, label in empty.items() if key not in nonempty} + + def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]: for snapshot in iter_hf_cache_snapshots(repo_id): variants, has_vision = list_local_gguf_variants(str(snapshot)) diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 8da0dc508f..38a2cab389 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -68,6 +68,11 @@ class TrainingStopRequest(PydanticBaseModel): router = APIRouter() logger = get_logger(__name__) +# Consecutive 1s polls without a step update that count as a stall. Applied only +# once stepping: the pre-first-step phase (model load + tokenization) can take far +# longer, and timing out there made a healthy long-prep run look frozen. +_PROGRESS_STALL_TIMEOUT_POLLS = 1800 # ~30 min at 1 poll/sec + def _validate_local_dataset_paths(paths: list[str], label: str = "Local dataset") -> list[str]: """Resolve and validate a list of local dataset paths. Returns validated absolute paths.""" @@ -833,7 +838,13 @@ async def stream_training_progress( # ── Live polling loop ──────────────────────────────────── last_step = resume_from_step if resume_from_step is not None else -1 no_update_count = 0 - max_no_updates = 1800 # Timeout after 30 min (large models need compile time) + # The stall timeout applies only once the run is stepping (pre-step prep + # may legitimately emit no step for a long time). On reconnect to an + # already-stepping run, seed from the resume point / history, else a worker + # that hangs after step N never times out for a client that reconnects past it. + seen_live_step = (resume_from_step is not None and resume_from_step > 0) or bool( + backend.step_history + ) while backend.is_training_active(): try: @@ -871,6 +882,7 @@ async def stream_training_progress( ) last_step = current_step no_update_count = 0 + seen_live_step = True else: no_update_count += 1 # Heartbeat every 10 seconds. @@ -913,8 +925,9 @@ async def stream_training_progress( event_id = 0, ) - # Timeout check - if no_update_count > max_no_updates: + # Fires only once stepping: a long pre-first-step prep phase is not + # a stall, and ending the stream there made a healthy run look frozen. + if seen_live_step and no_update_count > _PROGRESS_STALL_TIMEOUT_POLLS: logger.warning("Progress stream timeout - no updates received") tp_timeout = getattr( getattr(backend, "trainer", None), "training_progress", None diff --git a/studio/backend/tests/test_training_progress_prep_timeout.py b/studio/backend/tests/test_training_progress_prep_timeout.py new file mode 100644 index 0000000000..a7e6d4f839 --- /dev/null +++ b/studio/backend/tests/test_training_progress_prep_timeout.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""The live progress SSE must not time out during the pre-first-step phase. + +A large model load / dataset tokenization can keep a run at step 0 for longer +than the stall timeout. Treating that as a stall ends the live stream and makes a +healthy run look frozen, so the timeout must apply only once the run is stepping. +""" + +import asyncio +import sys +import types + +import pytest + +if "structlog" not in sys.modules: + + class _DummyLogger: + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + sys.modules["structlog"] = types.SimpleNamespace( + BoundLogger = _DummyLogger, + get_logger = lambda *args, **kwargs: _DummyLogger(), + ) + +import routes.training as rt + + +class _Progress: + def __init__( + self, + step = 0, + total_steps = 1000, + ): + self.step = step + self.total_steps = total_steps + self.loss = None + self.learning_rate = None + self.epoch = None + self.grad_norm = None + self.num_tokens = None + self.eval_loss = None + self.elapsed_seconds = None + self.eta_seconds = None + + +class _Backend: + def __init__( + self, + *, + active_polls, + step_history = None, + live_step = 0, + ): + self.current_job_id = "job-prep" + self.step_history = list(step_history or []) + self.loss_history = [1.0 for _ in self.step_history] + self.lr_history = [1e-4 for _ in self.step_history] + self.eval_enabled = False + self._active_calls = 0 + self._active_polls = active_polls + self.trainer = types.SimpleNamespace(training_progress = _Progress(step = live_step)) + + def is_training_active(self): + self._active_calls += 1 + return self._active_calls <= self._active_polls + + +class _FakeRequest: + headers = {} + + +class _ReconnectRequest: + # Reconnect carrying the last step the client already received. + headers = {"last-event-id": "10"} + + +def _raw(response): + async def _drain(): + chunks = [] + async for chunk in response.body_iterator: + chunks.append(chunk) + return "".join(c.decode() if isinstance(c, bytes) else c for c in chunks) + + return asyncio.run(asyncio.wait_for(_drain(), 15)) + + +@pytest.fixture +def _fast_short_timeout(monkeypatch): + """Make the poll loop instant and the stall timeout tiny.""" + + async def _no_sleep(*_a, **_k): + return None + + monkeypatch.setattr(rt.asyncio, "sleep", _no_sleep) + monkeypatch.setattr(rt, "_PROGRESS_STALL_TIMEOUT_POLLS", 3) + + +def test_prep_phase_does_not_time_out_before_first_step(monkeypatch, _fast_short_timeout): + # Step 0 for many polls (far past the timeout), then the run ends. Pre-step + # this is preparation, not a stall: no error event may be emitted. + backend = _Backend(active_polls = 20, step_history = [], live_step = 0) + monkeypatch.setattr(rt, "get_training_backend", lambda: backend) + + raw = _raw(asyncio.run(rt.stream_training_progress(_FakeRequest(), current_subject = "tester"))) + + assert ( + backend._active_calls > rt._PROGRESS_STALL_TIMEOUT_POLLS + 1 + ), "the loop must have run past the stall threshold for this test to be meaningful" + assert "event: heartbeat" in raw, "prep heartbeats should still flow" + assert "event: error" not in raw, "a still-preparing run must not be timed out as a stall" + + +def test_stall_after_first_step_still_times_out(monkeypatch, _fast_short_timeout): + # Emits a live step (so seen_live_step becomes True) then stays put: a genuine + # post-step stall that must still trigger the timeout error. + backend = _Backend(active_polls = 100, step_history = [1, 2], live_step = 5) + monkeypatch.setattr(rt, "get_training_backend", lambda: backend) + + raw = _raw(asyncio.run(rt.stream_training_progress(_FakeRequest(), current_subject = "tester"))) + + assert "event: error" in raw, "a real post-step stall should still time out" + + +def test_reconnect_to_stepped_run_still_times_out(monkeypatch, _fast_short_timeout): + # Client reconnects at step 10 (Last-Event-ID) to a run that already stepped + # then hangs (only heartbeats): the post-step stall timeout must still fire. + # Without seeding seen_live_step from the resume point it resets to False and + # never times out for this client. + backend = _Backend(active_polls = 100, step_history = [10], live_step = 10) + monkeypatch.setattr(rt, "get_training_backend", lambda: backend) + + raw = _raw( + asyncio.run(rt.stream_training_progress(_ReconnectRequest(), current_subject = "tester")) + ) + + assert ( + "event: error" in raw + ), "a reconnect to an already-stepped run that then stalls must still time out" diff --git a/studio/frontend/.npmrc b/studio/frontend/.npmrc index f2d15a4f15..19783b5ff4 100644 --- a/studio/frontend/.npmrc +++ b/studio/frontend/.npmrc @@ -14,9 +14,18 @@ min-release-age=7 # `npm install @ --save-exact` pass) but it stops new # carets from creeping into the manifest as patch-version footguns. save-exact=true -# Lock the registry. A user-set PIP_INDEX_URL-style override (here: -# NPM_CONFIG_REGISTRY env var or a stale ~/.npmrc) shouldn't redirect -# our installs to an attacker registry. +# Pin the default registry so a stale or hostile *lower-precedence* ~/.npmrc +# can't silently redirect our installs to an attacker registry. Note this does +# NOT block an ambient NPM_CONFIG_REGISTRY env var: npm and bun honor that at a +# higher precedence than this project file. That is exactly why Unsloth does not +# read NPM_CONFIG_REGISTRY and instead exposes one deliberate, explicit opt-in. +# +# Corporate mirror / proxy (issue #6491): if your firewall blocks +# registry.npmjs.org, set UNSLOTH_NPM_REGISTRY= when running +# ./install.sh (or setup.sh / setup.ps1). The installer threads it as +# `--registry `, which overrides this line for both npm and bun while +# leaving the min-release-age and save-exact locks above in force. Do not edit +# this line for that -- the env var keeps the default pinned for everyone else. registry=https://registry.npmjs.org/ audit-level=high fund=false diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 802f22e21e..914abbbf1d 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -6,6 +6,7 @@ import { UpdateBanner } from "@/components/tauri/update-banner"; import { UpdateScreen } from "@/components/tauri/update-screen"; import { WindowTitlebar, + shouldUseNativeMacWindowTitlebar, shouldUseCustomWindowTitlebar, } from "@/components/tauri/window-titlebar"; import { Toaster } from "@/components/ui/sonner"; @@ -18,9 +19,16 @@ import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend"; import { useTauriUpdate } from "@/hooks/use-tauri-update"; import { isTauri } from "@/lib/api-base"; +import { fetchDeviceType } from "@/config/env"; import { useRouterState } from "@tanstack/react-router"; import { ThemeProvider } from "next-themes"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; interface AppProviderProps { children: ReactNode; @@ -31,18 +39,43 @@ type WindowLayoutGuard = () => boolean; const MIN_WINDOW_WIDTH = 900; const MIN_WINDOW_HEIGHT = 600; +const SETUP_WINDOW_WIDTH = 760; +const SETUP_WINDOW_HEIGHT = 560; async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; const win = getCurrentWindow(); + await win.setResizable(false); + if (!isCurrent()) return; + await win.setSize(new LogicalSize(SETUP_WINDOW_WIDTH, SETUP_WINDOW_HEIGHT)); if (!isCurrent()) return; await win.center(); if (!isCurrent()) return; await win.show(); } +async function enforceMinimumWindowSize( + win: Awaited>, + LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"], + isCurrent: WindowLayoutGuard, +): Promise { + const [innerSize, scaleFactor] = await Promise.all([ + win.innerSize(), + win.scaleFactor(), + ]); + if (!isCurrent()) return; + + const logicalWidth = Math.round(innerSize.width / scaleFactor); + const logicalHeight = Math.round(innerSize.height / scaleFactor); + const nextWidth = Math.max(logicalWidth, MIN_WINDOW_WIDTH); + const nextHeight = Math.max(logicalHeight, MIN_WINDOW_HEIGHT); + if (nextWidth !== logicalWidth || nextHeight !== logicalHeight) { + await win.setSize(new LogicalSize(nextWidth, nextHeight)); + } +} + async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); const { invoke } = await import("@tauri-apps/api/core"); @@ -91,6 +124,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise // Apply constraints after restore/show: doing so before plugin restore can emit // a Resized event and overwrite the plugin's cached saved size. await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); + if (!isCurrent()) return; + await enforceMinimumWindowSize(win, LogicalSize, isCurrent); } async function showWindowFallback(): Promise { @@ -123,7 +158,13 @@ function getTauriWindowMode( } } -function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { +function TauriUpdateLayer({ + isExternalServer, + children, +}: { + isExternalServer: boolean; + children?: ReactNode; +}) { const update = useTauriUpdate(isExternalServer); const isUpdating = update.status === "updating-backend" || @@ -146,18 +187,22 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { } return ( - +
+ + {children} +
); } @@ -175,6 +220,35 @@ const WEB_UPDATE_HIDDEN_ROUTES = new Set([ "/signup", ]); +const MAC_NATIVE_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-mac-titlebar-height": "34px", + "--studio-mac-traffic-light-inset": "78px", + "--studio-startup-top-inset": "58px", + "--studio-content-top-inset": "0px", + "--studio-non-chat-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "44px", + "--studio-chat-header-padding-top": "8px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", +} as CSSProperties; + +const CUSTOM_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-custom-titlebar-height": "34px", + "--studio-sidebar-expanded-width": "17.5rem", + "--studio-sidebar-collapsed-width": "3rem", + "--studio-startup-top-inset": "42px", + "--studio-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "48px", + "--studio-chat-header-padding-top": "9px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", + "--studio-window-control-inset": "112px", +} as CSSProperties; + function TauriWrapper({ children }: { children: ReactNode }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const { @@ -254,6 +328,11 @@ function TauriWrapper({ children }: { children: ReactNode }) { return () => { disposed = true; }; }, [status, desktopAuthRetry]); + useEffect(() => { + if (!isTauri || status !== "running" || !desktopAuthReady) return; + void fetchDeviceType({ force: true }).catch(() => undefined); + }, [status, desktopAuthReady]); + if (!isTauri) { return ( <> @@ -281,10 +360,19 @@ function TauriWrapper({ children }: { children: ReactNode }) { status === "running" && !desktopAuthReady ? "Signing in to desktop session..." : progressDetail; + const usesCustomTitlebar = shouldUseCustomWindowTitlebar(); + const usesNativeMacTitlebar = shouldUseNativeMacWindowTitlebar(); + const hidesTitlebarSidebar = HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); const content = showApp ? ( <> - + + + + {children} @@ -305,39 +393,48 @@ function TauriWrapper({ children }: { children: ReactNode }) { /> ); - if (!shouldUseCustomWindowTitlebar()) { + if (!usesCustomTitlebar) { // macOS desktop uses the native titlebar and returns here before the // custom-titlebar branch, so mount the updater banner on this path too. - return ( - <> - {content} -
- - {showApp ? : null} + if (usesNativeMacTitlebar) { + return ( +
+ {(!showApp || hidesTitlebarSidebar) ? ( + - + ); + } + + return ( + <>{content} ); } const showSidebarSurface = - showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); + showApp && !hidesTitlebarSidebar; return ( -
+
-
+
{content}
-
- - {showApp ? : null} -
); } diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 77ba5788db..e5fa6f0191 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -219,7 +219,7 @@ function RootLayout() { {hideNavbar ? ( -
+
}> @@ -235,7 +235,7 @@ function RootLayout() {
{/* Stays mounted across navigation so an in-flight generation is not cancelled when leaving /chat; hidden (not unmounted) off-route. diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 7252105e5a..0905940609 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -44,7 +44,12 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; +import { + shouldUseCustomWindowTitlebar, + shouldUseNativeMacWindowTitlebar, +} from "@/components/tauri/window-titlebar"; import { cn } from "@/lib/utils"; +import { isTauri } from "@/lib/api-base"; import { useWebUpdateCheck } from "@/hooks/use-web-update-check"; import { Archive03Icon, @@ -273,6 +278,8 @@ function devForceUpdateCard(): boolean { export function AppSidebar() { const t = useT(); const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); + const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); const { pathname, search } = useRouterState({ select: (s) => ({ pathname: s.location.pathname, @@ -459,6 +466,10 @@ export function AppSidebar() { isStudioRoute, ]); + const chatDisabled = trainingInProgress; + const showSidebarBrand = !usesCustomTitlebar; + const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar; + function chatSearchForProject(projectId: string | null) { if (projectId) { return { project: projectId }; @@ -984,81 +995,118 @@ export function AppSidebar() { variant="sidebar" className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background" > - - {/* Expanded: compact logo + close toggle */} -
- { - event.preventDefault(); - openNewChat(null); - }} - className="flex items-center gap-[6px] select-none" - aria-label={t("shell.aria.home")} - > - Unsloth - - unsloth - - - {t("shell.beta")} - - - {!isMobile && ( - - - - - - {t("shell.aria.closeSidebar")} - - - )} -
- - {/* Collapsed: panel icon doubles as expand trigger */} - {!isMobile && ( -
- - - - - - {t("shell.aria.openSidebar")} - - -
+ Unsloth + + unsloth + + + {t("shell.beta")} + + + )} + {!isMobile && ( + + + + + + {t("shell.aria.closeSidebar")} + + + )} +
+ {!isMobile && ( +
+ + + + + + {t("shell.aria.openSidebar")} + + +
+ )} + )} {/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */} - + {t("common.help")} - { - // Best-effort server revocation; ignore network errors so - // the local clear still runs and the user lands on /login. - try { - await logout(); - } catch { - clearAuthTokens(); - } - void navigate({ to: "/login" }); - }} - > - - {t("shell.navigation.logOut")} - - setShutdownOpen(true)}> - - {t("common.shutdown")} - + {!isTauri && ( + { + // Best-effort server revocation; ignore network errors so + // the local clear still runs and the user lands on /login. + try { + await logout(); + } catch { + clearAuthTokens(); + } + void navigate({ to: "/login" }); + }} + > + + {t("shell.navigation.logOut")} + + )} + {!isTauri && ( + setShutdownOpen(true)}> + + {t("common.shutdown")} + + )} @@ -1584,11 +1636,13 @@ export function AppSidebar() { - + {!isTauri && ( + + )} { diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index bad9a6b7f3..a05910c29f 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -968,7 +968,9 @@ export const Thread: FC<{ scrollToBottomOnThreadSwitch={false} className={cn( "aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5", - hideComposer ? "pt-4" : "pt-[48px]", + hideComposer + ? "pt-4" + : "pt-[calc(var(--studio-content-top-inset,0px)+48px)]", )} > {!hideWelcome && ( diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 716c9d791f..44387f2480 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -1,13 +1,24 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import { shouldUseNativeMacWindowTitlebar } from "@/components/tauri/window-titlebar"; import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { useState } from "react"; export function Navbar() { const { isMobile } = useSidebar(); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); if (!isMobile) { return ( -
+
+ {usesNativeMacTitlebar && ( +
); } return ( diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx index fd67a8a841..678051b36b 100644 --- a/studio/frontend/src/components/tauri/startup-screen.tsx +++ b/studio/frontend/src/components/tauri/startup-screen.tsx @@ -433,12 +433,12 @@ export function StartupScreen({ } return ( -
-
+
+
void; onDismiss: () => void; onCopyDiagnostics: () => Promise; @@ -27,6 +31,11 @@ interface UpdateBannerProps { const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +function formatVersion(version: string | null | undefined): string { + if (!version) return ""; + return version.startsWith("v") ? version : `v${version}`; +} + export function UpdateBanner({ status, info, @@ -35,6 +44,7 @@ export function UpdateBanner({ isExternalServer = false, updatePolicyMode, manualReleaseUrl, + positioned = true, onInstall, onDismiss, onCopyDiagnostics, @@ -49,6 +59,9 @@ export function UpdateBanner({ const installDisabled = isManualLinuxPackage ? manualReleaseUrl === null : isExternalServer; + const currentVersion = formatVersion(info?.currentVersion); + const latestVersion = formatVersion(info?.version); + const Icon = showFailure ? CircleAlert : Download; async function handleCopyDiagnostics() { setCopying(true); @@ -59,7 +72,10 @@ export function UpdateBanner({ setManualMessage(null); } else { setManualReport(result.report); - setManualMessage(result.error ?? "Clipboard copy failed. Select and copy the diagnostics below."); + setManualMessage( + result.error ?? + "Clipboard copy failed. Select and copy the diagnostics below.", + ); } } catch (error) { setManualReport(null); @@ -73,30 +89,60 @@ export function UpdateBanner({ {show && ( -
+
-
- 🦥 -
-

- {showFailure ? "App update failed" : `New version: v${info?.version}`} +

+