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:
parent
c836228de8
commit
aba21db466
5 changed files with 188 additions and 79 deletions
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} →{" "}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue