From f436d204f611bef9921931c0515cdbed8e61c2dd Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 24 Jun 2026 17:34:18 -0700 Subject: [PATCH 1/8] Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) (#6639) * Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) On Apple Silicon, install.sh exports UV_OVERRIDE pointing at the bundled overrides-darwin-arm64.txt. uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path containing a space (e.g. /Users/me/Open Source/unsloth) truncates the value and every later uv call aborts with 'error: File not found: ' (the PyTorch install step in #6503). Copy the overrides file into a space-free temp dir and point uv at the copy when the path contains a space, mirroring the macOS/Linux handling already merged for the Python installer in #6534. The temp dir is removed in the exit trap, and the code falls back to the original path when no space-free temp dir is available, so the no-space and non-macOS paths are unchanged. Adds tests/sh/test_install_uv_override_space.sh, which extracts and runs the install.sh hardening block and checks the spaced, no-space, and spaced-TMPDIR fallback cases. * Installer: match all whitespace (not just spaces) in UV_OVERRIDE handling uv splits UV_OVERRIDE on any whitespace, so use the POSIX class *[[:space:]]* rather than a literal space in install.sh (catches tabs and newlines in the path too) and the matching test assertions. Use the portable awk bracket expression [$] instead of \$ in the extraction so the test runs the same under BSD awk (macOS) and GNU awk (Linux). Adds a tab-in-path case. * Installer: clear _UV_OVERRIDE_TMPDIR before the exit trap The exit trap rm -rf's _UV_OVERRIDE_TMPDIR. Initialize it to empty before registering the trap so an inherited environment value can never be removed; only a temp dir this script creates (Apple Silicon, spaced path) is cleaned. Adds a structural test asserting the init precedes the trap. * Run the install.sh UV_OVERRIDE space test in CI via a pytest wrapper The Shell installer tests job uses a fixed script list (not tests/run_all.sh), so the new shell test would not run on PRs. Add a pytest wrapper under tests/python/ that invokes it; the auto-discovered repo CPU test job collects tests/python/ and so executes the Apple Silicon spaced-path regression. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- install.sh | 23 ++++ .../python/test_install_uv_override_space.py | 31 +++++ tests/run_all.sh | 1 + tests/sh/test_install_uv_override_space.sh | 112 ++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 tests/python/test_install_uv_override_space.py create mode 100755 tests/sh/test_install_uv_override_space.sh 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/tests/python/test_install_uv_override_space.py b/tests/python/test_install_uv_override_space.py new file mode 100644 index 0000000000..86d918b043 --- /dev/null +++ b/tests/python/test_install_uv_override_space.py @@ -0,0 +1,31 @@ +"""Run the install.sh UV_OVERRIDE space-safety shell test (issue #6503) under +pytest, so the auto-discovered CPU test job executes it. The dedicated +`Shell installer tests` CI job runs a fixed script list that this is not part +of, so without this wrapper the regression would only be covered locally via +tests/run_all.sh. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SHELL_TEST = REPO_ROOT / "tests" / "sh" / "test_install_uv_override_space.sh" + + +@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX shell installer test") +@pytest.mark.skipif(shutil.which("bash") is None, reason = "bash not available") +def test_install_uv_override_space_shell(): + assert SHELL_TEST.is_file(), f"missing shell test: {SHELL_TEST}" + proc = subprocess.run( + ["bash", str(SHELL_TEST)], + capture_output = True, + text = True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "ALL PASSED" in proc.stdout, proc.stdout + proc.stderr diff --git a/tests/run_all.sh b/tests/run_all.sh index 18182d9db7..d03f4c4d4f 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -15,6 +15,7 @@ sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh" sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh" sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh" sh "$TESTS_DIR/sh/test_torch_flavor.sh" +sh "$TESTS_DIR/sh/test_install_uv_override_space.sh" echo "" echo "=== Python tests ===" diff --git a/tests/sh/test_install_uv_override_space.sh b/tests/sh/test_install_uv_override_space.sh new file mode 100755 index 0000000000..07ef36295a --- /dev/null +++ b/tests/sh/test_install_uv_override_space.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path with a space +# truncates it and aborts every later uv call (issue #6503). install.sh must hand +# uv a space-free copy. Exercises the real install.sh hardening block. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Extract the UV_OVERRIDE hardening block (outer case ... esac plus the export) +# and run it directly, so the test tracks install.sh rather than a copy of it. +BLOCK=$(awk ' + /case "[$]_OVERRIDES_FILE" in/ { grab = 1 } + grab { print } + grab && /export UV_OVERRIDE="[$]_OVERRIDES_FILE"/ { exit } +' "$INSTALL_SH") +if ! printf '%s' "$BLOCK" | grep -q 'export UV_OVERRIDE'; then + echo " FAIL: could not extract UV_OVERRIDE block from install.sh" + exit 1 +fi + +run_block() { + _OVERRIDES_FILE="$1" + _UV_OVERRIDE_TMPDIR="" + unset UV_OVERRIDE + eval "$BLOCK" +} + +echo "=== test_install_uv_override_space ===" + +# 1. Spaced path -> space-free copy with identical contents, temp dir tracked. +WORK=$(mktemp -d) +mkdir -p "$WORK/Open Source" +SRC="$WORK/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC" +run_block "$SRC" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "spaced path: UV_OVERRIDE still contains whitespace ($UV_OVERRIDE)" ;; + *) ok "spaced path: UV_OVERRIDE is whitespace-free" ;; +esac +[ "$UV_OVERRIDE" != "$SRC" ] && ok "spaced path: points at a copy" || bad "spaced path: not copied" +[ "$(cat "$UV_OVERRIDE" 2>/dev/null)" = "transformers>=4.57.6" ] \ + && ok "spaced path: copy contents identical" || bad "spaced path: contents differ" +{ [ -n "$_UV_OVERRIDE_TMPDIR" ] && [ -d "$_UV_OVERRIDE_TMPDIR" ]; } \ + && ok "spaced path: temp dir tracked for cleanup" || bad "spaced path: temp dir not tracked" +# The exit-trap cleanup (_on_install_exit) must then remove it. +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +[ ! -d "$_UV_OVERRIDE_TMPDIR" ] && ok "spaced path: temp dir removable" || bad "spaced path: temp dir lingers" +rm -rf "$WORK" + +# 2. No-space path -> passthrough, no temp dir. +PLAIN=$(mktemp -d) +PSRC="$PLAIN/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$PSRC" +run_block "$PSRC" +[ "$UV_OVERRIDE" = "$PSRC" ] && ok "no-space path: UV_OVERRIDE unchanged" || bad "no-space path: changed ($UV_OVERRIDE)" +[ -z "$_UV_OVERRIDE_TMPDIR" ] && ok "no-space path: no temp dir created" || bad "no-space path: temp dir created" +rm -rf "$PLAIN" + +# 3. TMPDIR itself contains a space -> fall back to the original path, no leak. +WORK2=$(mktemp -d) +mkdir -p "$WORK2/Open Source" "$WORK2/tmp dir" +SRC2="$WORK2/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC2" +RES=$( TMPDIR="$WORK2/tmp dir"; export TMPDIR; run_block "$SRC2" + printf 'UV_OVERRIDE=%s\nTMPDIR_VAR=%s\n' "$UV_OVERRIDE" "$_UV_OVERRIDE_TMPDIR" ) +echo "$RES" | grep -qx "UV_OVERRIDE=$SRC2" \ + && ok "spaced TMPDIR: falls back to original path" || bad "spaced TMPDIR: did not fall back ($RES)" +echo "$RES" | grep -qx "TMPDIR_VAR=" \ + && ok "spaced TMPDIR: no temp dir tracked" || bad "spaced TMPDIR: temp dir tracked" +# mktemp may have created a dir under the spaced TMPDIR; it must not be leaked. +_leftover=$(find "$WORK2/tmp dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n1) +[ -z "$_leftover" ] && ok "spaced TMPDIR: no leaked temp dir" || bad "spaced TMPDIR: leaked $_leftover" +rm -rf "$WORK2" + +# 4. A tab in the path is whitespace uv also splits on -> copied like a space. +WORK3=$(mktemp -d) +TABDIR=$(printf 'Open\tSource') +mkdir -p "$WORK3/$TABDIR" +SRC3="$WORK3/$TABDIR/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC3" +run_block "$SRC3" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "tab path: UV_OVERRIDE still contains whitespace" ;; + *) ok "tab path: UV_OVERRIDE is whitespace-free" ;; +esac +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +rm -rf "$WORK3" + +# 5. install.sh must clear _UV_OVERRIDE_TMPDIR before registering the exit trap, +# so an inherited value can never reach the trap's rm -rf. +_init_line=$(grep -n '^_UV_OVERRIDE_TMPDIR=""' "$INSTALL_SH" | head -n1 | cut -d: -f1) +_trap_line=$(grep -n '^trap _on_install_exit EXIT' "$INSTALL_SH" | head -n1 | cut -d: -f1) +{ [ -n "$_init_line" ] && [ -n "$_trap_line" ] && [ "$_init_line" -lt "$_trap_line" ]; } \ + && ok "init: _UV_OVERRIDE_TMPDIR cleared before exit trap" \ + || bad "init: _UV_OVERRIDE_TMPDIR not cleared before exit trap (init=$_init_line trap=$_trap_line)" + +echo "" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" From e25e7895a5024b3545d22b334c00b468b0f28141 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 25 Jun 2026 02:56:25 +0200 Subject: [PATCH 2/8] Polish Studio desktop chrome (#6332) * Polish Studio desktop chrome * Fix desktop chrome chat header overlap * Blend desktop titlebar with sidebar * Refine desktop chrome alignment * Fix desktop chrome review items * Reserve mac sidebar chrome space * Fix mac chrome review items * Polish macOS desktop chrome * Align macOS desktop chrome controls * Lower macOS traffic lights * Remove mac sidebar logo from chrome row * Match Tauri update banner styling * Update Tauri updater public key * Fix Tauri startup screen spacing * Work around AppImage WebKitGTK blank screen * Mark Linux AppImage as experimental * Address true desktop chrome review issues * Fix remaining desktop chrome review issues * Fix desktop titlebar inset review issues * Refresh desktop platform after backend auth --- .github/workflows/release-desktop.yml | 15 +- studio/frontend/src/app/provider.tsx | 171 ++++++++++--- studio/frontend/src/app/routes/__root.tsx | 4 +- .../frontend/src/components/app-sidebar.tsx | 242 +++++++++++------- .../src/components/assistant-ui/thread.tsx | 4 +- studio/frontend/src/components/navbar.tsx | 13 +- .../src/components/tauri/startup-screen.tsx | 6 +- .../src/components/tauri/update-banner.tsx | 127 +++++++-- .../src/components/tauri/update-screen.tsx | 4 +- .../src/components/tauri/window-titlebar.tsx | 183 +++++++++---- .../frontend/src/features/chat/chat-page.tsx | 53 ++-- .../src/features/chat/chat-settings-sheet.tsx | 9 +- .../src/features/settings/tabs/about-tab.tsx | 46 ++-- studio/src-tauri/icons/128x128.png | Bin 9194 -> 10181 bytes studio/src-tauri/icons/32x32.png | Bin 1930 -> 2065 bytes studio/src-tauri/icons/icon.icns | Bin 263568 -> 311926 bytes studio/src-tauri/icons/icon.ico | Bin 34589 -> 38034 bytes studio/src-tauri/icons/icon.png | Bin 46007 -> 42705 bytes studio/src-tauri/src/main.rs | 24 ++ studio/src-tauri/tauri.conf.json | 12 +- 20 files changed, 649 insertions(+), 264 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e747605322..884ff02d11 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -438,6 +438,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 @@ -580,9 +586,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 +618,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 +651,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/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 f563f9ae59..06f2701a16 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, @@ -272,6 +277,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, @@ -458,6 +465,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 }; @@ -983,81 +994,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")} + + )} @@ -1571,11 +1623,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}`} +

+