authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-14 17:58:49+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 17:58:49+02:00
log1a6599437b6ad77330923819613cc28be3b33945
tree2a72b6ef885dadd03d333250c21e164d2c403b99
parent184d8ef33b8f42869fb84f66a33984185b81dc47
signature Signed by PGP key B5690EEEBB952194

fix(web): clarify desktop update status (#6504)


2 files changed, 217 insertions(+), 22 deletions(-)

apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx created+126
......@@ -0,0 +1,126 @@
1import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react";
2import type { AnimationEventHandler } from "react";
3
4import { cn } from "../../lib/utils";
5
6const DOWNLOAD_PROGRESS_RADIUS = 14;
7const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
8
9export type DesktopUpdateStatusIconState =
10 | "idle"
11 | "checking"
12 | "available"
13 | "downloading"
14 | "downloaded";
15
16function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
17 if (percent === null || !Number.isFinite(percent)) return 0;
18 return Math.min(100, Math.max(0, percent));
19}
20
21export function shouldShowDesktopUpdateCheckIcon({
22 isAnimationLatched,
23 isChecking,
24 prefersReducedMotion,
25}: {
26 readonly isAnimationLatched: boolean;
27 readonly isChecking: boolean;
28 readonly prefersReducedMotion: boolean;
29}): boolean {
30 return isChecking || (isAnimationLatched && !prefersReducedMotion);
31}
32
33export function shouldContinueDesktopUpdateCheckAnimation({
34 isChecking,
35 prefersReducedMotion,
36}: {
37 readonly isChecking: boolean;
38 readonly prefersReducedMotion: boolean;
39}): boolean {
40 return isChecking && !prefersReducedMotion;
41}
42
43function DesktopUpdateAvailableIcon() {
44 return (
45 <span className="relative grid size-4 place-items-center">
46 <DownloadIcon className="size-4" />
47 <span
48 aria-hidden="true"
49 className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-update-foreground ring-2 ring-update-surface"
50 />
51 </span>
52 );
53}
54
55function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) {
56 const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent);
57 const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100);
58
59 return (
60 <span className="relative grid size-8 place-items-center">
61 <svg
62 aria-hidden="true"
63 className="pointer-events-none absolute inset-0 size-full -rotate-90"
64 viewBox="0 0 32 32"
65 >
66 <circle
67 cx="16"
68 cy="16"
69 r={DOWNLOAD_PROGRESS_RADIUS}
70 fill="none"
71 stroke="color-mix(in srgb, currentColor 22%, transparent)"
72 strokeWidth="1.5"
73 />
74 <circle
75 cx="16"
76 cy="16"
77 r={DOWNLOAD_PROGRESS_RADIUS}
78 fill="none"
79 stroke="currentColor"
80 strokeDasharray={DOWNLOAD_PROGRESS_CIRCUMFERENCE}
81 strokeDashoffset={progressOffset}
82 strokeLinecap="round"
83 strokeWidth="1.5"
84 className="transition-[stroke-dashoffset] duration-300 ease-out motion-reduce:transition-none"
85 />
86 </svg>
87 <DownloadIcon className="size-4" />
88 </span>
89 );
90}
91
92function DesktopUpdateDownloadedIcon() {
93 return (
94 <span className="relative grid size-4 place-items-center">
95 <RotateCwIcon className="size-4" />
96 <span className="absolute -right-1 -bottom-1 grid size-2.5 place-items-center rounded-full bg-update-foreground text-background ring-2 ring-background">
97 <CheckIcon className="size-2" strokeWidth={3} />
98 </span>
99 </span>
100 );
101}
102
103export function DesktopUpdateStatusIcon({
104 downloadPercent,
105 isCheckAnimating,
106 onCheckAnimationIteration,
107 status,
108}: {
109 readonly downloadPercent?: number | null;
110 readonly isCheckAnimating?: boolean;
111 readonly onCheckAnimationIteration?: AnimationEventHandler<SVGSVGElement>;
112 readonly status: DesktopUpdateStatusIconState;
113}) {
114 if (status === "available") return <DesktopUpdateAvailableIcon />;
115 if (status === "downloading") {
116 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
117 }
118 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
119
120 return (
121 <RefreshCwIcon
122 className={cn("size-4", status === "checking" && isCheckAnimating && "animate-spin")}
123 onAnimationIteration={onCheckAnimationIteration}
124 />
125 );
126}
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+91-22
......@@ -1,6 +1,7 @@
1import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { useCallback, useState } from "react";
1import { TriangleAlertIcon } from "lucide-react";
2import { useCallback, useEffect, useState } from "react";
33import { isElectron } from "../../env";
4import { useMediaQuery } from "../../hooks/useMediaQuery";
45import { cn } from "../../lib/utils";
56import { ensureLocalApi } from "../../localApi";
67import { useDesktopUpdateState } from "../../state/desktopUpdate";
......@@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
2122import { Separator } from "../ui/separator";
2223import { SidebarMenuItem } from "../ui/sidebar";
2324import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import {
26 DesktopUpdateStatusIcon,
27 shouldContinueDesktopUpdateCheckAnimation,
28 shouldShowDesktopUpdateCheckIcon,
29} from "./DesktopUpdateStatusIcon";
30
31function resolveSidebarUpdatePresentation({
32 action,
33 isDownloading,
34 showCheckIcon,
35}: {
36 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
37 readonly isDownloading: boolean;
38 readonly showCheckIcon: boolean;
39}) {
40 const showUpdateDetails = action !== "none" || isDownloading;
41 const iconStatus = showCheckIcon
42 ? "checking"
43 : action === "install"
44 ? "downloaded"
45 : isDownloading
46 ? "downloading"
47 : action === "download"
48 ? "available"
49 : "idle";
50
51 return {
52 iconStatus,
53 showUpdateDetails,
54 showUpdateIconState: showUpdateDetails && !showCheckIcon,
55 } as const;
56}
2457
2558function keyReleaseNoteItems(items: ReadonlyArray<string>) {
2659 const occurrences = new Map<string, number>();
......@@ -110,18 +143,42 @@ export function SidebarUpdatePill() {
110143function SidebarUpdateControl() {
111144 const state = useDesktopUpdateState();
112145 const [isActionPending, setIsActionPending] = useState(false);
146 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
147 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
148 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
149
150 useEffect(() => {
151 if (prefersReducedMotion) {
152 setIsCheckAnimationLatched(false);
153 } else if (state?.status === "checking") {
154 setIsCheckAnimationLatched(true);
155 }
156 }, [prefersReducedMotion, state?.status]);
113157
114158 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
115159 const isDownloading = state?.status === "downloading";
116 const isUpdateState = action !== "none" || isDownloading;
117 const tooltip = isUpdateState
160 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
161 isAnimationLatched: isCheckAnimationLatched,
162 isChecking: state?.status === "checking",
163 prefersReducedMotion,
164 });
165 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
166 action,
167 isDownloading,
168 showCheckIcon,
169 });
170 const tooltip = showUpdateDetails
118171 ? state
119172 ? getDesktopUpdateButtonTooltip(state)
120173 : "Update available"
121 : state?.status === "checking"
174 : showCheckIcon
122175 ? "Checking for updates…"
123176 : "Check for updates";
124 const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state);
177 const disabled = showCheckIcon
178 ? true
179 : showUpdateDetails
180 ? isDesktopUpdateButtonDisabled(state)
181 : !canCheckForUpdate(state);
125182
126183 const handleAction = useCallback(async () => {
127184 const bridge = window.desktopBridge;
......@@ -209,6 +266,10 @@ function SidebarUpdateControl() {
209266 return;
210267 }
211268
269 if (!prefersReducedMotion) {
270 setIsCheckAnimationLatched(true);
271 setCheckAnimationKey((key) => key + 1);
272 }
212273 void bridge
213274 .checkForUpdate()
214275 .then((result) => {
......@@ -232,7 +293,16 @@ function SidebarUpdateControl() {
232293 );
233294 })
234295 .finally(() => setIsActionPending(false));
235 }, [action, disabled, isActionPending, state]);
296 }, [action, disabled, isActionPending, prefersReducedMotion, state]);
297
298 const handleCheckAnimationIteration = useCallback(() => {
299 setIsCheckAnimationLatched(
300 shouldContinueDesktopUpdateCheckAnimation({
301 isChecking: state?.status === "checking",
302 prefersReducedMotion,
303 }),
304 );
305 }, [prefersReducedMotion, state?.status]);
236306
237307 return (
238308 <SidebarMenuItem className="ml-auto shrink-0">
......@@ -245,29 +315,28 @@ function SidebarUpdateControl() {
245315 aria-disabled={disabled || isActionPending || undefined}
246316 disabled={disabled || isActionPending}
247317 className={cn(
248 "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",
249 isUpdateState
318 "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",
319 showUpdateIconState
250320 ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"
251321 : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",
322 disabled && !showUpdateIconState && "opacity-60",
252323 )}
253324 onClick={handleAction}
254325 >
255 {action === "install" ? (
256 <RotateCwIcon className="size-4" />
257 ) : isUpdateState ? (
258 <DownloadIcon className="size-4" />
259 ) : (
260 <RefreshCwIcon
261 className={cn("size-4", state?.status === "checking" && "animate-spin")}
262 />
263 )}
326 <DesktopUpdateStatusIcon
327 key={showCheckIcon ? checkAnimationKey : iconStatus}
328 downloadPercent={state?.downloadPercent ?? null}
329 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
330 onCheckAnimationIteration={handleCheckAnimationIteration}
331 status={iconStatus}
332 />
264333 </button>
265334 }
266335 />
267336 <TooltipPopup
268337 align="center"
269338 className={
270 isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0
339 showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
271340 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
272341 // release notes stay open (and scrollable) when the cursor moves into them.
273342 "pointer-events-auto max-w-none text-balance"
......@@ -275,7 +344,7 @@ function SidebarUpdateControl() {
275344 }
276345 side="top"
277346 style={
278 isUpdateState
347 showUpdateDetails
279348 ? {
280349 background:
281350 "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
......@@ -283,9 +352,9 @@ function SidebarUpdateControl() {
283352 }
284353 : undefined
285354 }
286 variant={isUpdateState ? "glass" : "default"}
355 variant={showUpdateDetails ? "glass" : "default"}
287356 >
288 {isUpdateState && state ? (
357 {showUpdateDetails && state ? (
289358 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
290359 ) : (
291360 tooltip