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 ? (