diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 8d24b34a433..f0aed8c2854 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -10,6 +10,7 @@ import { getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, + resolveDesktopUpdateButtonTone, shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, @@ -108,6 +109,49 @@ describe("desktop update button state", () => { }); }); +describe("resolveDesktopUpdateButtonTone", () => { + it("stays quiet while the update downloads in the background", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "downloading", + availableVersion: "1.1.0", + downloadPercent: 42.5, + }), + ).toBe("quiet"); + }); + + it("calls for action once the update is downloaded", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }), + ).toBe("cta"); + }); + + it("calls for action when a failed download can be retried", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "available", + availableVersion: "1.1.0", + message: "network unavailable", + errorContext: "download", + canRetry: true, + }), + ).toBe("cta"); + }); + + it("stays idle when there is no update to act on", () => { + expect(resolveDesktopUpdateButtonTone(baseState)).toBe("idle"); + expect(resolveDesktopUpdateButtonTone({ ...baseState, status: "checking" })).toBe("idle"); + expect(resolveDesktopUpdateButtonTone(null)).toBe("idle"); + }); +}); + describe("getDesktopUpdateActionError", () => { it("returns user-visible message for accepted failed attempts", () => { const result: DesktopUpdateActionResult = { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index dc09d7ca877..b98e52de58b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -48,6 +48,24 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): return resolveDesktopUpdateButtonAction(state) !== "none"; } +export type DesktopUpdateButtonTone = "cta" | "quiet" | "idle"; + +/** + * The background download needs no input, so it stays quiet. Only a state that + * wants a click — install, or retry a failed download — gets call-to-action colour. + */ +export function resolveDesktopUpdateButtonTone( + state: DesktopUpdateState | null, +): DesktopUpdateButtonTone { + if (state && resolveDesktopUpdateButtonAction(state) !== "none") { + return "cta"; + } + if (state?.status === "downloading") { + return "quiet"; + } + return "idle"; +} + export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 838d2a1d9f7..7cef23bf1e8 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,4 +1,4 @@ -import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { ArrowUpCircleIcon, DownloadIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; @@ -13,6 +13,7 @@ import { getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, + resolveDesktopUpdateButtonTone, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; @@ -83,6 +84,56 @@ function SidebarUpdateReleaseNotesTooltip({ ); } +const DOWNLOAD_RING_RADIUS = 11; +const DOWNLOAD_RING_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_RING_RADIUS; + +/** + * A quiet progress ring for the background download. The main process only + * broadcasts progress every 10% (shouldBroadcastDownloadProgress), so the stroke + * is transitioned to glide between steps instead of jumping. Until the first + * progress event lands the state carries 0, which would draw a zero-length arc, + * so that spins a short arc rather than rendering an inert ring. + */ +function SidebarUpdateDownloadProgress({ percent }: { readonly percent: number | null }) { + const isDeterminate = typeof percent === "number" && percent > 0; + const dashOffset = isDeterminate + ? DOWNLOAD_RING_CIRCUMFERENCE * (1 - Math.min(100, percent) / 100) + : DOWNLOAD_RING_CIRCUMFERENCE * 0.75; + + return ( + <> + + + + ); +} + export function SidebarUpdateArchitectureWarning() { return isElectron ? : null; } @@ -114,6 +165,7 @@ function SidebarUpdateControl() { const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; const isUpdateState = action !== "none" || isDownloading; + const tone = resolveDesktopUpdateButtonTone(state); const tooltip = isUpdateState ? state ? getDesktopUpdateButtonTooltip(state) @@ -245,17 +297,22 @@ function SidebarUpdateControl() { aria-disabled={disabled || isActionPending || undefined} disabled={isActionPending || (!isUpdateState && disabled)} className={cn( - "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60", - isUpdateState - ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" - : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", + "relative inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2", + tone === "cta" && + "bg-update-surface text-update-foreground enabled:cursor-pointer enabled:hover:bg-update/12 disabled:cursor-not-allowed disabled:opacity-60", + // A manual retry keeps isActionPending true for the whole download, which + // natively disables the button. Downloading must look the same either way, + // so this branch deliberately omits the disabled dimming. + tone === "quiet" && "cursor-default text-[var(--sidebar-icon-color)]", + tone === "idle" && + "text-[var(--sidebar-icon-color)] enabled:cursor-pointer enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground disabled:cursor-not-allowed disabled:opacity-60", )} onClick={handleAction} > {action === "install" ? ( - + ) : isDownloading ? ( - + ) : isUpdateState ? ( ) : ( @@ -277,7 +334,7 @@ function SidebarUpdateControl() { } side="top" style={ - isUpdateState + tone === "cta" ? { background: "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", @@ -285,7 +342,7 @@ function SidebarUpdateControl() { } : undefined } - variant={isUpdateState ? "glass" : "default"} + variant={tone === "cta" ? "glass" : "default"} > {isUpdateState && state ? (