From d94834fbab8050bdf00f9895f9fc1c7f44d4722d Mon Sep 17 00:00:00 2001 From: dylanschroers <60888108+dylanschroers@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:26:51 -0400 Subject: [PATCH] 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 * 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 Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 --- .../frontend/src/components/app-sidebar.tsx | 92 +++++++++++++++++-- .../settings/stores/settings-dialog-store.ts | 23 ++++- .../src/features/settings/tabs/about-tab.tsx | 42 +++++++-- studio/frontend/src/i18n/locales/en.ts | 1 + studio/frontend/src/i18n/locales/zh-CN.ts | 1 + 5 files changed, 138 insertions(+), 21 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 734860138a..71956ff9db 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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() { )} - + {/* 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", )} /> - + + {/* Update affordance — shows only when a newer version is available. */} + {showUpdateCard && ( + + + + )} @@ -1381,11 +1454,16 @@ export function AppSidebar() { Unsloth {/* settings cog (replaces the up/down chevron) */} - + 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((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 }); }, })); diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx index fc4a0a953e..084a3ab5b6 100644 --- a/studio/frontend/src/features/settings/tabs/about-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx @@ -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; @@ -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(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 (
@@ -110,15 +130,17 @@ export function AboutTab() { Unsloth/Package rows; the prop keeps it About-only (General passes none). */} - -
- -
-
+
+ +
+ +
+
+
{hw.gpus.length > 0 || hw.cuda || hw.rocm ? ( diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index ddb8286b73..7cd68865dd 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -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", diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index c89b1daa46..7a6a5ee66d 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -23,6 +23,7 @@ export const zhCN = { }, shell: { accountMenu: "{name} 账号菜单", + updateAvailable: "有可用更新", aria: { home: "Unsloth 首页", closeSidebar: "关闭侧边栏",