diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 91d99e238e..f806a0f405 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -259,8 +259,16 @@ function TauriWrapper({ children }: { children: ReactNode }) { <> {children} - - +
+ + +
); } diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 83320f8e15..88826e57e5 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -5,13 +5,80 @@ 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 { cn } from "@/lib/utils"; import { AnimatePresence, motion } from "motion/react"; -import type { ReactElement } from "react"; +import { type ReactElement, useEffect, useRef, useState } from "react"; const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +// Backend progress is coarse (5% steps, ~0.9 max) and the extract tail emits no +// signal. Creep toward this cap so the bar keeps moving rather than freezing. +const RUNNING_CAP = 0.95; + +// Smoothed 0..1 bar progress: eases toward real `progress`, trickles toward a +// ceiling when idle, animates to 100% when `done`. Resets to 0 on each start. +function useSmoothedProgress( + active: boolean, + progress: number | null, + done: boolean, +): number { + const [display, setDisplay] = useState(0); + const displayRef = useRef(0); + const progressRef = useRef(progress); + const doneRef = useRef(done); + progressRef.current = progress; + doneRef.current = done; + + useEffect(() => { + if (!active) { + displayRef.current = 0; + setDisplay(0); + return; + } + let raf = 0; + let last = performance.now(); + const tick = (now: number) => { + // rAF timestamps can predate the performance.now() captured above, so + // clamp dt at 0 to keep the first frame from stepping backwards. + const dt = Math.max(0, Math.min((now - last) / 1000, 0.1)); + last = now; + const current = displayRef.current; + const real = progressRef.current ?? 0; + let target: number; + let speed: number; // approach rate (fraction of remaining gap per second) + if (doneRef.current) { + target = 1; + speed = 5; + } else if (real > current) { + target = real; // catch up to a freshly observed milestone + speed = 4; + } else { + target = RUNNING_CAP; // no signal: creep toward the cap, never frozen + speed = 0.3; + } + const cap = doneRef.current ? 1 : RUNNING_CAP; + const next = Math.min( + current + (target - current) * Math.min(speed * dt, 1), + cap, + ); + displayRef.current = next; + setDisplay(next); + if (doneRef.current && next > 0.999) { + return; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [active]); + + return display; +} interface LlamaUpdateBannerProps { enabled?: boolean; + // false: fill the parent instead of self-anchoring, so banners can stack in a + // shared container. true (default) keeps standalone desktop mounts working. + positioned?: boolean; } /** @@ -23,6 +90,7 @@ interface LlamaUpdateBannerProps { */ export function LlamaUpdateBanner({ enabled = true, + positioned = true, }: LlamaUpdateBannerProps): ReactElement | null { const showBannerPref = useShowLlamaUpdateBanner(); const { status, visible, applying, apply, dismiss, snooze } = @@ -46,6 +114,13 @@ export function LlamaUpdateBanner({ const show = visible && status != null && (status.update_available || applying); const updateProgress = status?.job.progress ?? null; + const jobSucceeded = status?.job.state === "success"; + // Drives the bar so it animates continuously; aria reports the real value. + const displayProgress = useSmoothedProgress( + applying, + updateProgress, + jobSucceeded, + ); return ( @@ -55,7 +130,11 @@ export function LlamaUpdateBanner({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8, scale: 0.97 }} transition={{ duration: 0.35, ease: EASE_OUT_QUART }} - className="fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[340px]" + className={cn( + positioned + ? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[340px]" + : "pointer-events-auto w-full", + )} data-testid="llama-update-banner" >
@@ -106,20 +185,14 @@ export function LlamaUpdateBanner({ aria-valuenow={ updateProgress != null ? Math.round(updateProgress * 100) - : undefined + : Math.round(displayProgress * 100) } data-testid="llama-update-progress" > - {updateProgress != null && updateProgress > 0 ? ( -
- ) : ( - // No percent yet (resolving the release): sweep until the - // first download progress arrives. -
- )} +
) : (
diff --git a/studio/frontend/src/components/web/update-banner.tsx b/studio/frontend/src/components/web/update-banner.tsx index 164d922baa..4aa3943d4f 100644 --- a/studio/frontend/src/components/web/update-banner.tsx +++ b/studio/frontend/src/components/web/update-banner.tsx @@ -2,32 +2,42 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; -import { usePlatformStore } from "@/config/env"; +import { type DeviceType, usePlatformStore } from "@/config/env"; import { useWebUpdateCheck } from "@/hooks/use-web-update-check"; import { isTauri } from "@/lib/api-base"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { cn } from "@/lib/utils"; import { AnimatePresence, motion } from "motion/react"; import { type ReactElement, useEffect, useRef, useState } from "react"; -const STUDIO_INSTALL_UNIX_CMD = - "curl -fsSL https://unsloth.ai/install.sh | sh"; +// macOS, Linux and WSL update via the POSIX installer; only native Windows +// (PowerShell) needs the irm one-liner. Any non-windows device_type (incl. wsl) +// resolves to the curl command below. +const STUDIO_INSTALL_UNIX_CMD = "curl -fsSL https://unsloth.ai/install.sh | sh"; const STUDIO_INSTALL_WINDOWS_CMD = "irm https://unsloth.ai/install.ps1 | iex"; const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"; const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +function installCommandForDevice(deviceType: DeviceType): string { + return deviceType === "windows" + ? STUDIO_INSTALL_WINDOWS_CMD + : STUDIO_INSTALL_UNIX_CMD; +} + interface WebUpdateBannerProps { enabled?: boolean; + // false: fill the parent instead of self-anchoring, so it can stack with the + // llama.cpp banner. true (default) keeps standalone mounts working. + positioned?: boolean; } export function WebUpdateBanner({ enabled = true, + positioned = true, }: WebUpdateBannerProps): ReactElement | null { - const { status, dismiss } = useWebUpdateCheck({ enabled }); + const { status, dismiss, snooze } = useWebUpdateCheck({ enabled }); const deviceType = usePlatformStore((s) => s.deviceType); - const installCmd = - deviceType === "windows" - ? STUDIO_INSTALL_WINDOWS_CMD - : STUDIO_INSTALL_UNIX_CMD; + const installCmd = installCommandForDevice(deviceType); const [copiedVersion, setCopiedVersion] = useState(null); const dismissTimerRef = useRef | null>(null); @@ -51,30 +61,39 @@ export function WebUpdateBanner({ if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); } - dismissTimerRef.current = setTimeout(() => dismiss(), 900); + // Copying is not updating: snooze instead of dismissing, so the banner + // returns on the next launch if the install is still behind. + dismissTimerRef.current = setTimeout(() => snooze(), 1200); } + const copied = status != null && copiedVersion === status.latestVersion; + return ( {status ? ( -
+
-
- -
-

- Package update available: {status.latestVersion} +

+

+ New Unsloth version +

+
+

+ {status.currentVersion} →{" "} + + {status.latestVersion} +

-

- Installed package: {status.currentVersion}. To update Unsloth, - run this in your terminal, then restart Unsloth. -

-
-
- -
- -
+
+ +
+
diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index 5c609558d6..9532ee0735 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -11,8 +11,9 @@ const FIRST_CHECK_DELAY_MS = 1000; const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour // "Remind me later" re-surfaces sooner than the hourly reminder. const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes -// While an update is applying, poll the job state at this cadence. -const JOB_POLL_INTERVAL_MS = 1500; +// Poll cadence while applying. Short so the installer's ~5% milestones are +// observed instead of a fast download finishing between two slow polls. +const JOB_POLL_INTERVAL_MS = 500; export interface LlamaUpdateJob { state: "idle" | "running" | "success" | "error"; @@ -149,7 +150,14 @@ export function useLlamaUpdateCheck({ ); useEffect(() => { - if (!enabled) return; + if (!enabled) { + // Disabled mid-update: stop showing and tracking, and clear `applying` + // so the banner's animation loop stops too. Re-enabling re-detects a + // still-running job below and resumes tracking via surfaceIfAvailable. + setVisible(false); + setApplying(false); + return; + } let canceled = false; const firstTimer = setTimeout(() => { diff --git a/studio/frontend/src/hooks/use-web-update-check.ts b/studio/frontend/src/hooks/use-web-update-check.ts index 1ad0fe544c..78d53528f4 100644 --- a/studio/frontend/src/hooks/use-web-update-check.ts +++ b/studio/frontend/src/hooks/use-web-update-check.ts @@ -5,6 +5,8 @@ import { getAuthToken } from "@/features/auth"; import { apiUrl, isTauri } from "@/lib/api-base"; import { useCallback, useEffect, useState } from "react"; +// Checked once per launch only (no polling), to avoid background load. A +// re-check happens naturally the next time the user reopens the app. const WEB_UPDATE_CHECK_DELAY_MS = 5000; const DISMISS_PREFIX = "unsloth_web_update_dismissed"; const CAN_SHOW_KEY = "can_show_web_notification"; @@ -119,24 +121,21 @@ export function useWebUpdateCheck({ useEffect(() => { if (isTauri || !enabled || !getAuthToken()) { - const clearTimer = window.setTimeout(() => setStatus(null), 0); - return () => window.clearTimeout(clearTimer); + setStatus(null); + return; } let canceled = false; + // One check per launch, 5s after load. No polling: the next re-check is + // simply the next time the user opens the app. const timer = window.setTimeout(() => { fetchDisplayableUpdateStatus() - .then((nextStatus) => { - if (canceled) { - return; + .then((next) => { + if (!canceled && next && !isDismissed(next)) { + setStatus(next); } - setStatus(nextStatus && !isDismissed(nextStatus) ? nextStatus : null); }) - .catch(() => { - if (!canceled) { - setStatus(null); - } - }); + .catch(() => {}); }, delayMs); return () => { @@ -145,6 +144,7 @@ export function useWebUpdateCheck({ }; }, [delayMs, enabled]); + // X: silence this version for good (persists across launches). const dismiss = useCallback(() => { setStatus((current) => { if (current) { @@ -154,5 +154,12 @@ export function useWebUpdateCheck({ }); }, []); - return { status: enabled && !isTauri ? status : null, dismiss }; + // "Remind me later" / post-copy: hide without persisting a dismissal, so the + // banner returns on the next launch if the install is still behind. Copying + // the command is not the same as having updated. + const snooze = useCallback(() => { + setStatus(null); + }, []); + + return { status: enabled && !isTauri ? status : null, dismiss, snooze }; }