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 @@...@@ -1,6 +1,7 @@
1import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";1import { TriangleAlertIcon } from "lucide-react";
2import { useCallback, useState } from "react";2import { useCallback, useEffect, useState } from "react";
3import { isElectron } from "../../env";3import { isElectron } from "../../env";
4import { useMediaQuery } from "../../hooks/useMediaQuery";
4import { cn } from "../../lib/utils";5import { cn } from "../../lib/utils";
5import { ensureLocalApi } from "../../localApi";6import { ensureLocalApi } from "../../localApi";
6import { useDesktopUpdateState } from "../../state/desktopUpdate";7import { useDesktopUpdateState } from "../../state/desktopUpdate";
...@@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";...@@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
21import { Separator } from "../ui/separator";22import { Separator } from "../ui/separator";
22import { SidebarMenuItem } from "../ui/sidebar";23import { SidebarMenuItem } from "../ui/sidebar";
23import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";24import { 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
25function keyReleaseNoteItems(items: ReadonlyArray<string>) {58function keyReleaseNoteItems(items: ReadonlyArray<string>) {
26 const occurrences = new Map<string, number>();59 const occurrences = new Map<string, number>();
...@@ -110,18 +143,42 @@ export function SidebarUpdatePill() {...@@ -110,18 +143,42 @@ export function SidebarUpdatePill() {
110function SidebarUpdateControl() {143function SidebarUpdateControl() {
111 const state = useDesktopUpdateState();144 const state = useDesktopUpdateState();
112 const [isActionPending, setIsActionPending] = useState(false);145 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
114 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";158 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
115 const isDownloading = state?.status === "downloading";159 const isDownloading = state?.status === "downloading";
116 const isUpdateState = action !== "none" || isDownloading;160 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
117 const tooltip = isUpdateState161 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
118 ? state171 ? state
119 ? getDesktopUpdateButtonTooltip(state)172 ? getDesktopUpdateButtonTooltip(state)
120 : "Update available"173 : "Update available"
121 : state?.status === "checking"174 : showCheckIcon
122 ? "Checking for updates…"175 ? "Checking for updates…"
123 : "Check for updates";176 : "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
126 const handleAction = useCallback(async () => {183 const handleAction = useCallback(async () => {
127 const bridge = window.desktopBridge;184 const bridge = window.desktopBridge;
...@@ -209,6 +266,10 @@ function SidebarUpdateControl() {...@@ -209,6 +266,10 @@ function SidebarUpdateControl() {
209 return;266 return;
210 }267 }
211268
269 if (!prefersReducedMotion) {
270 setIsCheckAnimationLatched(true);
271 setCheckAnimationKey((key) => key + 1);
272 }
212 void bridge273 void bridge
213 .checkForUpdate()274 .checkForUpdate()
214 .then((result) => {275 .then((result) => {
...@@ -232,7 +293,16 @@ function SidebarUpdateControl() {...@@ -232,7 +293,16 @@ function SidebarUpdateControl() {
232 );293 );
233 })294 })
234 .finally(() => setIsActionPending(false));295 .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
237 return (307 return (
238 <SidebarMenuItem className="ml-auto shrink-0">308 <SidebarMenuItem className="ml-auto shrink-0">
...@@ -245,29 +315,28 @@ function SidebarUpdateControl() {...@@ -245,29 +315,28 @@ function SidebarUpdateControl() {
245 aria-disabled={disabled || isActionPending || undefined}315 aria-disabled={disabled || isActionPending || undefined}
246 disabled={disabled || isActionPending}316 disabled={disabled || isActionPending}
247 className={cn(317 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",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",
249 isUpdateState319 showUpdateIconState
250 ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"320 ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"
251 : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",321 : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",
322 disabled && !showUpdateIconState && "opacity-60",
252 )}323 )}
253 onClick={handleAction}324 onClick={handleAction}
254 >325 >
255 {action === "install" ? (326 <DesktopUpdateStatusIcon
256 <RotateCwIcon className="size-4" />327 key={showCheckIcon ? checkAnimationKey : iconStatus}
257 ) : isUpdateState ? (328 downloadPercent={state?.downloadPercent ?? null}
258 <DownloadIcon className="size-4" />329 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
259 ) : (330 onCheckAnimationIteration={handleCheckAnimationIteration}
260 <RefreshCwIcon331 status={iconStatus}
261 className={cn("size-4", state?.status === "checking" && "animate-spin")}332 />
262 />
263 )}
264 </button>333 </button>
265 }334 }
266 />335 />
267 <TooltipPopup336 <TooltipPopup
268 align="center"337 align="center"
269 className={338 className={
270 isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0339 showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
271 ? // pointer-events-auto overrides the positioner's pointer-events-none so the340 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
272 // release notes stay open (and scrollable) when the cursor moves into them.341 // release notes stay open (and scrollable) when the cursor moves into them.
273 "pointer-events-auto max-w-none text-balance"342 "pointer-events-auto max-w-none text-balance"
...@@ -275,7 +344,7 @@ function SidebarUpdateControl() {...@@ -275,7 +344,7 @@ function SidebarUpdateControl() {
275 }344 }
276 side="top"345 side="top"
277 style={346 style={
278 isUpdateState347 showUpdateDetails
279 ? {348 ? {
280 background:349 background:
281 "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",350 "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
...@@ -283,9 +352,9 @@ function SidebarUpdateControl() {...@@ -283,9 +352,9 @@ function SidebarUpdateControl() {
283 }352 }
284 : undefined353 : undefined
285 }354 }
286 variant={isUpdateState ? "glass" : "default"}355 variant={showUpdateDetails ? "glass" : "default"}
287 >356 >
288 {isUpdateState && state ? (357 {showUpdateDetails && state ? (
289 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />358 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
290 ) : (359 ) : (
291 tooltip360 tooltip