feat: add update instructions card with OS toggle and mobile expand flow (#4721)

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
This commit is contained in:
Lee Jackson 2026-03-31 11:05:05 +01:00 committed by GitHub
commit 815619d972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -6,6 +6,11 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import {
Sheet,
@ -16,20 +21,25 @@ import {
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
ArrowReloadHorizontalIcon,
ArrowRight01Icon,
Cancel01Icon,
Book03Icon,
BubbleChatIcon,
ChefHatIcon,
Copy01Icon,
CursorInfo02Icon,
PackageIcon,
Tick02Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { useTrainingRuntimeStore } from "@/features/training";
import { usePlatformStore } from "@/config/env";
import { Link, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import { ShutdownDialog } from "@/components/shutdown-dialog";
@ -41,6 +51,185 @@ const NAV_ITEMS = [
{ label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true },
];
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
type UpdateShell = "windows" | "unix";
function getDefaultUpdateShell(deviceType: string): UpdateShell {
return deviceType === "windows" ? "windows" : "unix";
}
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
}
function CopyableCommand({
command,
copyLabel,
}: {
command: string;
copyLabel: string;
}): ReactElement {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = () => {
if (!copyToClipboard(command)) {
return;
}
setCopied(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
aria-label={`${copyLabel} text`}
/>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
</button>
</div>
);
}
function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
}, [defaultShell]);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div
className={cn(
"flex items-center gap-3",
showTitle ? "justify-between" : "justify-start",
)}
>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
Update Unsloth Studio
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShell("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</div>
);
}
function getTourId(pathname: string): "studio" | "chat" | "export" | null {
if (pathname === "/studio") return "studio";
if (pathname === "/chat") return "chat";
@ -52,9 +241,12 @@ export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileUpdateOpen, setMobileUpdateOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const deviceType = usePlatformStore((s) => s.deviceType);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const defaultUpdateShell = getDefaultUpdateShell(deviceType);
// Warn before closing the tab only when training is running (data loss risk).
// We store the handler in a ref so removeUnloadHandler() can clean it up
@ -236,6 +428,25 @@ export function Navbar() {
<span className="text-sm font-medium">Tour</span>
</button>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<button
type="button"
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="How to update Unsloth Studio"
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-[22.5rem] p-0">
<UpdateStudioInstructions
className="p-4"
defaultShell={defaultUpdateShell}
/>
</HoverCardContent>
</HoverCard>
<button
type="button"
onClick={() => setShutdownOpen(true)}
@ -259,7 +470,13 @@ export function Navbar() {
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<Sheet
open={mobileOpen}
onOpenChange={(open) => {
setMobileOpen(open);
if (!open) setMobileUpdateOpen(false);
}}
>
<SheetTrigger asChild={true}>
<button
type="button"
@ -273,7 +490,7 @@ export function Navbar() {
<SheetHeader>
<SheetTitle>Navigate</SheetTitle>
</SheetHeader>
<div className="mt-6 flex flex-col gap-2">
<div className="mt-6 flex max-h-[calc(100dvh-8rem)] flex-col gap-2 overflow-y-auto pr-1">
{NAV_ITEMS.filter((item) => item.enabled).map((item) => {
const active = pathname === item.href;
const disabledByTraining =
@ -312,7 +529,7 @@ export function Navbar() {
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
@ -331,9 +548,40 @@ export function Navbar() {
Start tour
</button>
) : null}
<Collapsible
open={mobileUpdateOpen}
onOpenChange={setMobileUpdateOpen}
className="rounded-md border border-border"
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
aria-label="Toggle update instructions"
>
<span className="flex items-center gap-2">
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update Unsloth Studio
</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className={cn(
"size-4 text-muted-foreground transition-transform",
mobileUpdateOpen && "rotate-90",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border p-3 pt-2">
<UpdateStudioInstructions
defaultShell={defaultUpdateShell}
showTitle={false}
/>
</CollapsibleContent>
</Collapsible>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
setMobileOpen(false);
setShutdownOpen(true);