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:
parent
69d8a57ee9
commit
d94834fbab
5 changed files with 138 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const zhCN = {
|
|||
},
|
||||
shell: {
|
||||
accountMenu: "{name} 账号菜单",
|
||||
updateAvailable: "有可用更新",
|
||||
aria: {
|
||||
home: "Unsloth 首页",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue