Studio: add sidebar update button with installed-version display (#6545)

* studio: add sidebar update button (static design only)

Adds a clock-icon update card above the account button in the sidebar footer. Visual/layout only; update detection and click behavior are wired in follow-ups.

* studio: show installed version in sidebar update card + collapse to icon

Replaces the placeholder version with the real installed app version via @tauri-apps/api/app getVersion() (Tauri-only; hidden in browser). Collapsed sidebar now shows just the clock icon instead of hiding the card.

* studio: open Settings About (update section) when the sidebar update card is clicked

* studio: i18n the sidebar update button label and add aria-label

Address Gemini Code Assist review on #6545:
- wrap the hardcoded "Update available" label in t() (shell.updateAvailable,
  en + zh-CN), matching the rest of the sidebar
- add aria-label so the collapsed icon-only button has an accessible name

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* studio: hide sidebar update card unless an update is available

Gates the card on useWebUpdateCheck so it stays hidden by default on both web and desktop, appearing only when the installed PyPI version is behind the latest release. Includes a TEMP localStorage dev override (devForceUpdateCard) to preview the card where there is no real update; remove before merge. Keeps the i18n label/aria-label; desktop (Tauri updater) detection not wired yet.

* Polish Studio sidebar update affordance

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
This commit is contained in:
dylanschroers 2026-06-23 09:26:51 -04:00 committed by GitHub
commit d94834fbab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 138 additions and 21 deletions

View file

@ -45,8 +45,11 @@ import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import {
Archive03Icon,
ArrowRight02Icon,
BadgeInfoIcon,
ChefHatIcon,
CursorInfo02Icon,
DashboardCircleIcon,
@ -253,6 +256,19 @@ function NavItem({
);
}
// TEMP DEV override: preview the update card on installs with no real update
// (e.g. an editable checkout). In the browser console run
// `localStorage.setItem("unsloth_force_update_card", "1")` and reload. Remove
// before merge.
function devForceUpdateCard(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem("unsloth_force_update_card") === "1";
} catch {
return false;
}
}
export function AppSidebar() {
const t = useT();
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
@ -265,6 +281,16 @@ export function AppSidebar() {
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
const navigate = useNavigate();
// Web update detection: `webUpdate` is non-null only when the installed
// (PyPI) version is behind the latest release, so the card is hidden by
// default. `forceUpdateCard` is a TEMP dev override to preview it on installs
// with no real update (e.g. an editable checkout); remove before merge.
const { status: webUpdate } = useWebUpdateCheck();
const [forceUpdateCard] = useState(devForceUpdateCard);
const showUpdateCard = Boolean(webUpdate) || forceUpdateCard;
const updateVersion =
webUpdate?.latestVersion ?? (forceUpdateCard ? "0.0.0" : null);
// Auto-close mobile Sheet after navigation
const closeMobileIfOpen = () => {
if (isMobile) setOpenMobile(false);
@ -1348,7 +1374,7 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
<SidebarFooter className="relative pt-3 pb-4 group-data-[collapsible=icon]:px-0">
{/* Fade above the profile box, shown only when there's more list below
the fold; at the bottom (or short lists) it fades so the last row
shows fully (Gemini-style). right-2 keeps it clear of the 8px scrollbar gutter. */}
@ -1359,7 +1385,54 @@ export function AppSidebar() {
canScrollDown ? "opacity-100" : "opacity-0",
)}
/>
<SidebarMenu>
<SidebarMenu className="gap-3 group-data-[collapsible=icon]:gap-2.5">
{/* Update affordance — shows only when a newer version is available. */}
{showUpdateCard && (
<SidebarMenuItem>
<button
type="button"
aria-label={t("shell.updateAvailable")}
onClick={() => {
useSettingsDialogStore
.getState()
.openDialog("about", { scrollTarget: "about-updates" });
closeMobileIfOpen();
}}
className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
>
<span
aria-hidden="true"
className="flex size-[32px] shrink-0 items-center justify-center group-data-[collapsible=icon]:size-full"
>
<HugeiconsIcon
icon={BadgeInfoIcon}
strokeWidth={1.75}
className="size-[21px] text-nav-fg"
/>
</span>
<div className="flex min-w-0 flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13.5px] font-semibold text-nav-fg">
{t("shell.updateAvailable")}
</span>
{updateVersion && (
<span className="truncate text-[11.5px] text-muted-foreground">
v{updateVersion}
</span>
)}
</div>
<span
aria-hidden="true"
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
>
<HugeiconsIcon
icon={ArrowRight02Icon}
className="size-[17px]"
strokeWidth={1.75}
/>
</span>
</button>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1381,11 +1454,16 @@ export function AppSidebar() {
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
</div>
{/* settings cog (replaces the up/down chevron) */}
<HugeiconsIcon
icon={Settings02Icon}
strokeWidth={1.5}
className="ml-auto !size-[18px] text-muted-foreground group-data-[collapsible=icon]:hidden"
/>
<span
aria-hidden="true"
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
>
<HugeiconsIcon
icon={Settings02Icon}
strokeWidth={1.5}
className="!size-[18px]"
/>
</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -12,9 +12,16 @@ export type SettingsTab =
| "api-keys"
| "about";
export type SettingsScrollTarget = "about-updates";
interface OpenDialogOptions {
scrollTarget?: SettingsScrollTarget;
}
interface SettingsDialogState {
open: boolean;
activeTab: SettingsTab;
scrollTarget: SettingsScrollTarget | null;
// Element focused when openDialog() ran. Radix's FocusScope normally tracks
// this, but the rAF-scheduled focus() in settings-dialog.tsx races its
// previous-focus capture, leaving focus on <body> after close. We restore
@ -23,9 +30,10 @@ interface SettingsDialogState {
// Set when something asks to jump straight to the archived chats list (the
// archive toast). ChatTab consumes it to open the dialog, then clears it.
archivedChatsRequested: boolean;
openDialog: (tab?: SettingsTab) => void;
openDialog: (tab?: SettingsTab, options?: OpenDialogOptions) => void;
openArchivedChats: () => void;
consumeArchivedChatsRequest: () => void;
consumeScrollTarget: (target: SettingsScrollTarget) => void;
closeDialog: () => void;
setActiveTab: (tab: SettingsTab) => void;
}
@ -65,32 +73,39 @@ function loadInitialTab(): SettingsTab {
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
open: false,
activeTab: loadInitialTab(),
scrollTarget: null,
opener: null,
archivedChatsRequested: false,
openDialog: (tab) =>
openDialog: (tab, options) =>
set((state) => ({
open: true,
activeTab: tab ?? state.activeTab,
scrollTarget: options?.scrollTarget ?? null,
opener: captureOpener(),
})),
openArchivedChats: () =>
set({
open: true,
activeTab: "chat",
scrollTarget: null,
archivedChatsRequested: true,
opener: captureOpener(),
}),
consumeArchivedChatsRequest: () => set({ archivedChatsRequested: false }),
consumeScrollTarget: (target) =>
set((state) => ({
scrollTarget: state.scrollTarget === target ? null : state.scrollTarget,
})),
// Do NOT clear `opener` here. onCloseAutoFocus runs on the next render
// pass after `open: false` lands, so the opener must still be readable
// from the store at that point. The next openDialog() overwrites it.
closeDialog: () => set({ open: false }),
closeDialog: () => set({ open: false, scrollTarget: null }),
setActiveTab: (tab) => {
try {
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
} catch {
// ignore storage failures
}
set({ activeTab: tab });
set({ activeTab: tab, scrollTarget: null });
},
}));

View file

@ -17,7 +17,7 @@ import {
NewReleasesIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
@ -25,6 +25,7 @@ import {
type UpdateInstallSource,
UpdateStudioInstructions,
} from "../components/update-studio-instructions";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
type ApiObject = Record<string, unknown>;
@ -76,6 +77,11 @@ export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const hw = useHardwareInfo();
const updateSectionRef = useRef<HTMLDivElement | null>(null);
const scrollTarget = useSettingsDialogStore((s) => s.scrollTarget);
const consumeScrollTarget = useSettingsDialogStore(
(s) => s.consumeScrollTarget,
);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
@ -95,6 +101,20 @@ export function AboutTab() {
};
}, []);
useEffect(() => {
if (scrollTarget !== "about-updates") {
return;
}
const frame = window.requestAnimationFrame(() => {
updateSectionRef.current?.scrollIntoView({
block: "start",
behavior: "smooth",
});
consumeScrollTarget("about-updates");
});
return () => window.cancelAnimationFrame(frame);
}, [consumeScrollTarget, scrollTarget]);
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
@ -110,15 +130,17 @@ export function AboutTab() {
Unsloth/Package rows; the prop keeps it About-only (General passes none). */}
<StudioVersionSection llamaCppVersion={hw.llamaCpp} />
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
<div ref={updateSectionRef} className="scroll-mt-5">
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
</div>
{hw.gpus.length > 0 || hw.cuda || hw.rocm ? (
<SettingsSection title={t("settings.about.hardware")}>

View file

@ -23,6 +23,7 @@ export const en = {
brand: "unsloth",
product: "Unsloth Studio",
accountMenu: "{name} account menu",
updateAvailable: "Update available",
aria: {
home: "Unsloth home",
closeSidebar: "Close sidebar",

View file

@ -23,6 +23,7 @@ export const zhCN = {
},
shell: {
accountMenu: "{name} 账号菜单",
updateAvailable: "有可用更新",
aria: {
home: "Unsloth 首页",
closeSidebar: "关闭侧边栏",