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: "关闭侧边栏",