Studio: bottom update banners, smooth llama.cpp progress, re-prompt after copy (#6233)

Web "New Unsloth version" banner now matches the llama.cpp banner design
(borderless rounded card, drop shadow, heading title) and moves to the
bottom-right, stacked with the llama.cpp banner in a shared container. Keeps
the Release notes link, and the copy command is platform aware (curl for
macOS/Linux/WSL, irm for Windows).

The web banner is checked once per launch only, with no polling; a re-check
just happens the next time the user opens the app. Copying the install
command no longer dismisses it for good: it hides and returns on the next
launch while the install is still behind. The X button still dismisses the
version permanently.

llama.cpp progress bar: poll the job every 500ms and smooth the displayed
value so it animates with the real download instead of snapping to the ~90%
post-download hold and finishing.
This commit is contained in:
Michael Han 2026-06-12 04:18:57 -07:00 committed by GitHub
commit aba21db466
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 188 additions and 79 deletions

View file

@ -259,8 +259,16 @@ function TauriWrapper({ children }: { children: ReactNode }) {
<>
{children}
<DownloadManagerPanel />
<WebUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
<LlamaUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[340px] flex-col items-stretch gap-2">
<WebUpdateBanner
positioned={false}
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}
/>
<LlamaUpdateBanner
positioned={false}
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}
/>
</div>
</>
);
}

View file

@ -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<number | null>(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 (
<AnimatePresence>
@ -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"
>
<div className="relative overflow-hidden rounded-[24px] bg-white px-4 pb-[22px] pl-6 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
@ -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 ? (
<div
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
style={{ width: `${Math.round(updateProgress * 100)}%` }}
/>
) : (
// No percent yet (resolving the release): sweep until the
// first download progress arrives.
<div className="loading-bar-slide h-full w-1/3 rounded-full bg-primary" />
)}
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.max(displayProgress * 100, 2)}%` }}
/>
</div>
) : (
<div className="mt-3 flex items-center gap-2">

View file

@ -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<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<AnimatePresence>
{status ? (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[380px]"
className={cn(
positioned
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[340px]"
: "pointer-events-auto w-full",
)}
data-testid="web-update-banner"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
<div className="relative overflow-hidden rounded-[24px] bg-white px-4 pb-[22px] pl-6 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<button
type="button"
onClick={dismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss update notification"
>
<svg
aria-hidden="true"
width="14"
height="14"
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -88,52 +107,46 @@ export function WebUpdateBanner({
</svg>
</button>
<div className="flex items-start gap-2 pr-5">
<span className="text-lg" aria-hidden="true">
🦥
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Package update available: {status.latestVersion}
<div className="min-w-0 pr-6">
<p className="font-heading text-base font-medium text-foreground">
New Unsloth version
</p>
<div className="mt-0.5 flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{status.currentVersion} &rarr;{" "}
<span className="font-medium text-foreground">
{status.latestVersion}
</span>
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Installed package: {status.currentVersion}. To update Unsloth,
run this in your terminal, then restart Unsloth.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="corner-squircle"
onClick={handleCopyCommand}
>
{copiedVersion === status.latestVersion
? "Copied"
: "Copy command"}
</Button>
<Button
size="sm"
variant="outline"
className="corner-squircle"
asChild={true}
>
<a
href={RELEASE_NOTES_URL}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
data-testid="web-update-release-notes-link"
>
Release notes
</a>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
className="h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleCopyCommand}
data-testid="web-update-copy-button"
>
{copied ? "Copied" : "Copy command"}
</Button>
<Button
size="sm"
variant="ghost"
className="corner-squircle"
onClick={dismiss}
className="h-auto rounded-full px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground"
onClick={snooze}
data-testid="web-update-snooze-button"
>
Later
Remind me later
</Button>
</div>
</div>

View file

@ -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(() => {

View file

@ -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 };
}