diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
new file mode 100644
index 00000000000..9346a742a37
--- /dev/null
+++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
@@ -0,0 +1,126 @@
+import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react";
+import type { AnimationEventHandler } from "react";
+
+import { cn } from "../../lib/utils";
+
+const DOWNLOAD_PROGRESS_RADIUS = 14;
+const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
+
+export type DesktopUpdateStatusIconState =
+ | "idle"
+ | "checking"
+ | "available"
+ | "downloading"
+ | "downloaded";
+
+function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
+ if (percent === null || !Number.isFinite(percent)) return 0;
+ return Math.min(100, Math.max(0, percent));
+}
+
+export function shouldShowDesktopUpdateCheckIcon({
+ isAnimationLatched,
+ isChecking,
+ prefersReducedMotion,
+}: {
+ readonly isAnimationLatched: boolean;
+ readonly isChecking: boolean;
+ readonly prefersReducedMotion: boolean;
+}): boolean {
+ return isChecking || (isAnimationLatched && !prefersReducedMotion);
+}
+
+export function shouldContinueDesktopUpdateCheckAnimation({
+ isChecking,
+ prefersReducedMotion,
+}: {
+ readonly isChecking: boolean;
+ readonly prefersReducedMotion: boolean;
+}): boolean {
+ return isChecking && !prefersReducedMotion;
+}
+
+function DesktopUpdateAvailableIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) {
+ const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent);
+ const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100);
+
+ return (
+
+
+
+
+ );
+}
+
+function DesktopUpdateDownloadedIcon() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function DesktopUpdateStatusIcon({
+ downloadPercent,
+ isCheckAnimating,
+ onCheckAnimationIteration,
+ status,
+}: {
+ readonly downloadPercent?: number | null;
+ readonly isCheckAnimating?: boolean;
+ readonly onCheckAnimationIteration?: AnimationEventHandler;
+ readonly status: DesktopUpdateStatusIconState;
+}) {
+ if (status === "available") return ;
+ if (status === "downloading") {
+ return ;
+ }
+ if (status === "downloaded") return ;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 191f30438dd..c5cffd8110b 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -1,6 +1,7 @@
-import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
-import { useCallback, useState } from "react";
+import { TriangleAlertIcon } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
import { isElectron } from "../../env";
+import { useMediaQuery } from "../../hooks/useMediaQuery";
import { cn } from "../../lib/utils";
import { ensureLocalApi } from "../../localApi";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
@@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Separator } from "../ui/separator";
import { SidebarMenuItem } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import {
+ DesktopUpdateStatusIcon,
+ shouldContinueDesktopUpdateCheckAnimation,
+ shouldShowDesktopUpdateCheckIcon,
+} from "./DesktopUpdateStatusIcon";
+
+function resolveSidebarUpdatePresentation({
+ action,
+ isDownloading,
+ showCheckIcon,
+}: {
+ readonly action: ReturnType;
+ readonly isDownloading: boolean;
+ readonly showCheckIcon: boolean;
+}) {
+ const showUpdateDetails = action !== "none" || isDownloading;
+ const iconStatus = showCheckIcon
+ ? "checking"
+ : action === "install"
+ ? "downloaded"
+ : isDownloading
+ ? "downloading"
+ : action === "download"
+ ? "available"
+ : "idle";
+
+ return {
+ iconStatus,
+ showUpdateDetails,
+ showUpdateIconState: showUpdateDetails && !showCheckIcon,
+ } as const;
+}
function keyReleaseNoteItems(items: ReadonlyArray) {
const occurrences = new Map();
@@ -110,18 +143,42 @@ export function SidebarUpdatePill() {
function SidebarUpdateControl() {
const state = useDesktopUpdateState();
const [isActionPending, setIsActionPending] = useState(false);
+ const [checkAnimationKey, setCheckAnimationKey] = useState(0);
+ const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
+
+ useEffect(() => {
+ if (prefersReducedMotion) {
+ setIsCheckAnimationLatched(false);
+ } else if (state?.status === "checking") {
+ setIsCheckAnimationLatched(true);
+ }
+ }, [prefersReducedMotion, state?.status]);
const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
const isDownloading = state?.status === "downloading";
- const isUpdateState = action !== "none" || isDownloading;
- const tooltip = isUpdateState
+ const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
+ isAnimationLatched: isCheckAnimationLatched,
+ isChecking: state?.status === "checking",
+ prefersReducedMotion,
+ });
+ const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
+ action,
+ isDownloading,
+ showCheckIcon,
+ });
+ const tooltip = showUpdateDetails
? state
? getDesktopUpdateButtonTooltip(state)
: "Update available"
- : state?.status === "checking"
+ : showCheckIcon
? "Checking for updates…"
: "Check for updates";
- const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state);
+ const disabled = showCheckIcon
+ ? true
+ : showUpdateDetails
+ ? isDesktopUpdateButtonDisabled(state)
+ : !canCheckForUpdate(state);
const handleAction = useCallback(async () => {
const bridge = window.desktopBridge;
@@ -209,6 +266,10 @@ function SidebarUpdateControl() {
return;
}
+ if (!prefersReducedMotion) {
+ setIsCheckAnimationLatched(true);
+ setCheckAnimationKey((key) => key + 1);
+ }
void bridge
.checkForUpdate()
.then((result) => {
@@ -232,7 +293,16 @@ function SidebarUpdateControl() {
);
})
.finally(() => setIsActionPending(false));
- }, [action, disabled, isActionPending, state]);
+ }, [action, disabled, isActionPending, prefersReducedMotion, state]);
+
+ const handleCheckAnimationIteration = useCallback(() => {
+ setIsCheckAnimationLatched(
+ shouldContinueDesktopUpdateCheckAnimation({
+ isChecking: state?.status === "checking",
+ prefersReducedMotion,
+ }),
+ );
+ }, [prefersReducedMotion, state?.status]);
return (
@@ -245,29 +315,28 @@ function SidebarUpdateControl() {
aria-disabled={disabled || isActionPending || undefined}
disabled={disabled || isActionPending}
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
+ "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",
+ showUpdateIconState
? "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",
+ disabled && !showUpdateIconState && "opacity-60",
)}
onClick={handleAction}
>
- {action === "install" ? (
-
- ) : isUpdateState ? (
-
- ) : (
-
- )}
+
}
/>
0
+ showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
? // pointer-events-auto overrides the positioner's pointer-events-none so the
// release notes stay open (and scrollable) when the cursor moves into them.
"pointer-events-auto max-w-none text-balance"
@@ -275,7 +344,7 @@ function SidebarUpdateControl() {
}
side="top"
style={
- isUpdateState
+ showUpdateDetails
? {
background:
"color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
@@ -283,9 +352,9 @@ function SidebarUpdateControl() {
}
: undefined
}
- variant={isUpdateState ? "glass" : "default"}
+ variant={showUpdateDetails ? "glass" : "default"}
>
- {isUpdateState && state ? (
+ {showUpdateDetails && state ? (
) : (
tooltip