From 15d3c74b854d2cbce1207ca3d965d1ae26664caa Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 05:43:07 +0100 Subject: [PATCH 1/9] fix(web): remove the update button's idle background --- apps/web/src/components/sidebar/SidebarUpdatePill.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 6871b9149fd48..f4a207037b515 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -458,10 +458,7 @@ function SidebarUpdateControl() { "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2 focus-visible:ring-inset", isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer", showUpdateIconState - ? cn( - "bg-sidebar-control-surface text-sidebar-foreground", - !isInteractionDisabled && "hover:bg-sidebar-row-hover", - ) + ? cn("text-sidebar-foreground", !isInteractionDisabled && "hover:bg-sidebar-row-hover") : cn( "text-(--sidebar-icon-color)", !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", From b0f6977deb4539e52f7af97f75d5a41bbacf7202 Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 08:40:45 +0100 Subject: [PATCH 2/9] redesign: use the update arrow in the idle state --- apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 9b8b388044f55..2d310be8c2dd0 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -116,7 +116,9 @@ export function DesktopUpdateStatusIcon({ return ; } if (status === "downloaded") return ; - if (status === "everything") return ; + if (status === "idle" || status === "everything") { + return ; + } return ( Date: Sat, 7 Nov 2026 09:39:33 +0100 Subject: [PATCH 3/9] redesign: keep automatic update checks static --- .../sidebar/DesktopUpdateStatusIcon.tsx | 22 ----- .../SidebarUpdatePill.browser.test.tsx | 89 ++++++++++++++++++- .../components/sidebar/SidebarUpdatePill.tsx | 64 +++++-------- 3 files changed, 107 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 2d310be8c2dd0..b4d4b40dd7dda 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -18,28 +18,6 @@ function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { 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 ( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 03bb2c72849e2..495054d6252a1 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -1,9 +1,9 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { act, render } from "../../../test/render"; +import { act, render, stubGlobal } from "../../../test/render"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; const mocks = vi.hoisted(() => { @@ -83,6 +83,91 @@ function publish(update: DesktopUpdateState | null) { const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); +describe("SidebarUpdatePill manual checks", () => { + it.each(["success", "failure"] as const)( + "finishes the manual rotation after %s and keeps automatic checks static", + async (outcome) => { + const idle: DesktopUpdateState = { + ...nightlyUpdate([]), + status: "idle", + availableVersion: null, + }; + setup(idle); + const check = Promise.withResolvers>>(); + using _bridge = stubGlobal("desktopBridge", { checkForUpdate: () => check.promise }); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Check for updates" }).element(); + + await publish({ ...idle, status: "checking" }); + expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + expect(button.getAttribute("aria-disabled")).toBe("true"); + await publish(idle); + + await userEvent.click(button); + const spinner = button.querySelector('[class*="visible-animate-spin"]'); + expect(spinner).not.toBeNull(); + // Drive rotation boundaries explicitly so the IPC response cannot race the browser's animation. + spinner!.style.animationPlayState = "paused"; + await publish({ ...idle, status: "checking" }); + await act(() => + spinner!.dispatchEvent(new AnimationEvent("animationiteration", { bubbles: true })), + ); + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(button.contains(spinner)).toBe(true); + + await act(async () => { + mocks.update = idle; + mocks.notify(); + if (outcome === "success") check.resolve({ checked: true, state: idle }); + else check.reject(new Error("Update check failed")); + await check.promise.catch(() => undefined); + }); + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(button.contains(spinner)).toBe(true); + + await act(() => + spinner!.dispatchEvent(new AnimationEvent("animationiteration", { bubbles: true })), + ); + expect(button.hasAttribute("aria-disabled")).toBe(false); + expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + expect(button.querySelector(".lucide-circle-arrow-up")).not.toBeNull(); + + await publish({ ...idle, status: "checking" }); + expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + }, + ); + + it("finishes immediately with reduced motion", async () => { + const idle: DesktopUpdateState = { + ...nightlyUpdate([]), + status: "idle", + availableVersion: null, + }; + setup(idle); + const matchMedia = window.matchMedia.bind(window); + using _media = vi.spyOn(window, "matchMedia").mockImplementation((query) => { + const result = matchMedia(query); + if (query === "(prefers-reduced-motion: reduce)") { + Object.defineProperty(result, "matches", { value: true }); + } + return result; + }); + const check = Promise.withResolvers>>(); + using _bridge = stubGlobal("desktopBridge", { checkForUpdate: () => check.promise }); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Check for updates" }).element(); + await userEvent.click(button); + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + + await act(async () => { + check.resolve({ checked: true, state: idle }); + await check.promise; + }); + expect(button.hasAttribute("aria-disabled")).toBe(false); + }); +}); + describe("SidebarUpdatePill release notes", () => { it("closes when the release notes stop applying", async () => { setup(nightlyUpdate(RELEASE_NOTES)); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index f4a207037b515..d391da5193545 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -35,8 +35,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { DesktopUpdateStatusIcon, type DesktopUpdateStatusIconState, - shouldContinueDesktopUpdateCheckAnimation, - shouldShowDesktopUpdateCheckIcon, } from "./DesktopUpdateStatusIcon"; import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; import { SidebarUpdateCapsule } from "./SidebarUpdateCapsule"; @@ -200,31 +198,18 @@ function SidebarUpdateControl() { // The desktop updates this machine, whose policy the primary environment reports. const updatesDenial = managedUpdatesDenial(usePrimaryManagedPolicy()); const [isActionPending, setIsActionPending] = useState(false); + const [manualCheckStatus, setManualCheckStatus] = useState<"idle" | "pending" | "finishing">( + "idle", + ); const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); - const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); const suppressReleaseNotesFocusOpen = useRef(false); const releaseNotesPopupRef = useRef(null); const releaseNotesTriggerId = useId(); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); - - const updateStatus = state?.status; - const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState( - () => !prefersReducedMotion && updateStatus === "checking", - ); - const [latchInputs, setLatchInputs] = useState({ prefersReducedMotion, updateStatus }); - if ( - latchInputs.prefersReducedMotion !== prefersReducedMotion || - latchInputs.updateStatus !== updateStatus - ) { - setLatchInputs({ prefersReducedMotion, updateStatus }); - if (prefersReducedMotion) { - setIsCheckAnimationLatched(false); - } else if (updateStatus === "checking") { - setIsCheckAnimationLatched(true); - } + if (prefersReducedMotion && manualCheckStatus === "finishing") { + setManualCheckStatus("idle"); } - const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; const localUpdateVersion = @@ -233,11 +218,8 @@ function SidebarUpdateControl() { const runsEverything = everything.hasUpdates && !isDownloading; const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; - const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ - isAnimationLatched: isCheckAnimationLatched, - isChecking: state?.status === "checking", - prefersReducedMotion, - }); + const showCheckIcon = manualCheckStatus !== "idle"; + const isCheckingForUpdates = state?.status === "checking" || showCheckIcon; const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, showsEverything: runsEverything, @@ -246,17 +228,19 @@ function SidebarUpdateControl() { showCheckIcon, }); const idleTooltip = - !showUpdateDetails && !showCheckIcon ? getDesktopUpdateIdleTooltip(state, updatesDenial) : null; + !showUpdateDetails && !isCheckingForUpdates + ? getDesktopUpdateIdleTooltip(state, updatesDenial) + : null; const tooltip = runsEverything ? everything.summary : showUpdateDetails ? state ? getDesktopUpdateButtonTooltip(state) : "Update available" - : showCheckIcon + : isCheckingForUpdates ? "Checking for updates…" : (idleTooltip?.title ?? "Check for updates"); - const disabled = showCheckIcon + const disabled = isCheckingForUpdates ? true : runsEverything ? false @@ -321,10 +305,7 @@ function SidebarUpdateControl() { return; } - if (!prefersReducedMotion) { - setIsCheckAnimationLatched(true); - setCheckAnimationKey((key) => key + 1); - } + setManualCheckStatus("pending"); void bridge .checkForUpdate() .then((result) => { @@ -349,7 +330,10 @@ function SidebarUpdateControl() { }), ); }) - .finally(() => setIsActionPending(false)); + .finally(() => { + setManualCheckStatus("finishing"); + setIsActionPending(false); + }); }; const runEverything = async () => { @@ -382,23 +366,15 @@ function SidebarUpdateControl() { setIsActionPending(false); }; - const handleCheckAnimationIteration = () => { - setIsCheckAnimationLatched( - shouldContinueDesktopUpdateCheckAnimation({ - isChecking: state?.status === "checking", - prefersReducedMotion, - }), - ); - }; - if (!isElectron && !runsEverything && !isRunningEverything) return null; const statusIcon = ( { + if (manualCheckStatus === "finishing") setManualCheckStatus("idle"); + }} status={iconStatus} /> ); From 81c84df7205ea585cb1e9a7ed9e2dd4c6da1fd29 Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 09:40:23 +0100 Subject: [PATCH 4/9] redesign: animate transitions between update icons --- .../sidebar/DesktopUpdateStatusIcon.tsx | 49 ++++++++++++++----- .../SidebarUpdatePill.browser.test.tsx | 14 +++++- .../components/sidebar/SidebarUpdatePill.tsx | 19 ++++--- apps/web/src/index.css | 26 ++++++++++ 4 files changed, 86 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index b4d4b40dd7dda..e752cfd483c28 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -81,28 +81,51 @@ function DesktopUpdateDownloadedIcon() { export function DesktopUpdateStatusIcon({ downloadPercent, isCheckAnimating, + isCheckReturning, onCheckAnimationIteration, + onCheckReturnComplete, status, }: { readonly downloadPercent?: number | null; readonly isCheckAnimating?: boolean; + readonly isCheckReturning?: boolean; readonly onCheckAnimationIteration?: AnimationEventHandler; + readonly onCheckReturnComplete?: () => void; readonly status: DesktopUpdateStatusIconState; }) { - if (status === "available") return ; - if (status === "downloading") { - return ; - } - if (status === "downloaded") return ; - if (status === "idle" || status === "everything") { - return ; - } + const statusIcon = + status === "available" ? ( + + ) : status === "downloading" ? ( + + ) : status === "downloaded" ? ( + + ) : status === "checking" ? ( + + ) : ( + + ); + + if (!isCheckAnimating && !isCheckReturning) return statusIcon; return ( - + + + {isCheckReturning ? : } + + + {statusIcon} + + ); } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 495054d6252a1..796a1b481f1d3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -128,9 +128,19 @@ describe("SidebarUpdatePill manual checks", () => { await act(() => spinner!.dispatchEvent(new AnimationEvent("animationiteration", { bubbles: true })), ); - expect(button.hasAttribute("aria-disabled")).toBe(false); + expect(button.getAttribute("aria-disabled")).toBe("true"); expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); - expect(button.querySelector(".lucide-circle-arrow-up")).not.toBeNull(); + const returnIcon = button.querySelector(".lucide-circle-arrow-up"); + expect(returnIcon).not.toBeNull(); + await act(() => + returnIcon!.dispatchEvent( + new AnimationEvent("animationend", { + animationName: "desktop-update-check-incoming", + bubbles: true, + }), + ), + ); + expect(button.hasAttribute("aria-disabled")).toBe(false); await publish({ ...idle, status: "checking" }); expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d391da5193545..d5b90bbecf2cd 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -198,16 +198,19 @@ function SidebarUpdateControl() { // The desktop updates this machine, whose policy the primary environment reports. const updatesDenial = managedUpdatesDenial(usePrimaryManagedPolicy()); const [isActionPending, setIsActionPending] = useState(false); - const [manualCheckStatus, setManualCheckStatus] = useState<"idle" | "pending" | "finishing">( - "idle", - ); + const [manualCheckStatus, setManualCheckStatus] = useState< + "idle" | "pending" | "finishing" | "returning" + >("idle"); const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); const suppressReleaseNotesFocusOpen = useRef(false); const releaseNotesPopupRef = useRef(null); const releaseNotesTriggerId = useId(); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); - if (prefersReducedMotion && manualCheckStatus === "finishing") { + if ( + prefersReducedMotion && + (manualCheckStatus === "finishing" || manualCheckStatus === "returning") + ) { setManualCheckStatus("idle"); } const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; @@ -218,8 +221,8 @@ function SidebarUpdateControl() { const runsEverything = everything.hasUpdates && !isDownloading; const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; - const showCheckIcon = manualCheckStatus !== "idle"; - const isCheckingForUpdates = state?.status === "checking" || showCheckIcon; + const showCheckIcon = manualCheckStatus === "pending" || manualCheckStatus === "finishing"; + const isCheckingForUpdates = state?.status === "checking" || manualCheckStatus !== "idle"; const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, showsEverything: runsEverything, @@ -372,9 +375,11 @@ function SidebarUpdateControl() { { - if (manualCheckStatus === "finishing") setManualCheckStatus("idle"); + if (manualCheckStatus === "finishing") setManualCheckStatus("returning"); }} + onCheckReturnComplete={() => setManualCheckStatus("idle")} status={iconStatus} /> ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3f3e1c742f4cf..27315caf6114d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2422,6 +2422,32 @@ code { } } +@utility desktop-update-check-outgoing { + animation: desktop-update-check-outgoing 120ms ease-in both; +} + +@utility desktop-update-check-incoming { + animation: desktop-update-check-incoming 160ms 120ms ease-out both; +} + +@keyframes desktop-update-check-outgoing { + from { + transform: rotate(0deg) scale(1); + } + to { + transform: rotate(90deg) scale(0); + } +} + +@keyframes desktop-update-check-incoming { + from { + transform: rotate(-90deg) scale(0); + } + to { + transform: rotate(0deg) scale(1); + } +} + /* Rich text composer (Tiptap). The stored prompt stays markdown; only the surface renders styled text. Markers (** * ` ~~) appear as non-editable widgets while the caret is on styled text. */ From b5ef20b2404269c888e5887be9ff58dd1ef94da0 Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 12:23:15 +0100 Subject: [PATCH 5/9] redesign: integrate download feedback into the update icon --- .../sidebar/DesktopUpdateStatusIcon.tsx | 113 ++++++++++-------- .../SidebarUpdatePill.browser.test.tsx | 45 +++++++ apps/web/src/index.css | 4 + 3 files changed, 115 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index e752cfd483c28..84fd4133621c1 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,8 +1,9 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { CircleArrowUpIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; -const DOWNLOAD_PROGRESS_RADIUS = 14; +const DOWNLOAD_PROGRESS_RADIUS = 10; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; export type DesktopUpdateStatusIconState = @@ -18,61 +19,80 @@ function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { return Math.min(100, Math.max(0, percent)); } -function DesktopUpdateAvailableIcon() { - return ( - - - - ); -} - -function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) { - const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent); +function DesktopUpdateDownloadIcon({ + percent, + complete, +}: { + readonly percent: number | null; + readonly complete: boolean; +}) { + const normalizedPercent = complete ? 100 : normalizeDesktopUpdateDownloadPercent(percent); const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100); return ( - + - ); -} - -function DesktopUpdateDownloadedIcon() { - return ( - - - - + + + + + + + + + ); @@ -94,20 +114,19 @@ export function DesktopUpdateStatusIcon({ readonly status: DesktopUpdateStatusIconState; }) { const statusIcon = - status === "available" ? ( - - ) : status === "downloading" ? ( - - ) : status === "downloaded" ? ( - + status === "downloading" || status === "downloaded" ? ( + ) : status === "checking" ? ( ) : ( - + ); if (!isCheckAnimating && !isCheckReturning) return statusIcon; @@ -115,10 +134,10 @@ export function DesktopUpdateStatusIcon({ return ( - {isCheckReturning ? : } + {isCheckReturning ? : } { }); }); +describe("SidebarUpdatePill downloads", () => { + it("finishes the progress ring while making restart available", async () => { + const downloading: DesktopUpdateState = { + ...nightlyUpdate([]), + status: "downloading", + downloadPercent: 95, + }; + setup(downloading); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Downloading update (95%)" }).element(); + const progress = button.querySelector("circle[stroke-dasharray]")!; + const ring = progress.parentElement!; + expect(getComputedStyle(ring).opacity).toBe("1"); + expect(button.getAttribute("aria-disabled")).toBe("true"); + // Hold the transition until assertions finish, independent of the browser's frame timing. + for (const element of button.querySelectorAll("span, svg, circle")) { + element.style.transitionDuration = "600s"; + element.style.transitionDelay = "0s"; + } + + const downloaded: DesktopUpdateState = { + ...downloading, + status: "downloaded", + downloadPercent: null, + downloadedVersion: downloading.availableVersion, + }; + await publish(downloaded); + expect(button.getAttribute("aria-label")).toContain("Click to restart and install"); + expect(button.hasAttribute("aria-disabled")).toBe(false); + expect(button.contains(progress)).toBe(true); + const transitions = button.getAnimations({ subtree: true }); + expect(transitions.length).toBeGreaterThan(0); + expect(transitions.every((transition) => transition.effect?.getTiming().iterations === 1)).toBe( + true, + ); + + for (const transition of transitions) transition.finish(); + await Promise.all(transitions.map((transition) => transition.finished)); + expect(getComputedStyle(ring).opacity).toBe("0"); + expect(button.getAnimations({ subtree: true })).toHaveLength(0); + await publish({ ...downloaded }); + expect(button.getAnimations({ subtree: true })).toHaveLength(0); + }); +}); + describe("SidebarUpdatePill update everything", () => { it("does not refocus the update button after a pointer cancel", async () => { setup(null, { hasUpdates: true }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 27315caf6114d..ac70b405f6f0d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2430,6 +2430,10 @@ code { animation: desktop-update-check-incoming 160ms 120ms ease-out both; } +@utility desktop-update-restart-ease { + transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); +} + @keyframes desktop-update-check-outgoing { from { transform: rotate(0deg) scale(1); From 8133e89d8c99617a33eadfd06570003ac5b57452 Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 12:23:33 +0100 Subject: [PATCH 6/9] redesign: smooth manual update-check transitions --- .../sidebar/SidebarUpdatePill.browser.test.tsx | 17 +++++++++++++---- apps/web/src/index.css | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index f74792258fde3..67b89886f48e6 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -84,7 +84,7 @@ function publish(update: DesktopUpdateState | null) { const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill manual checks", () => { - it.each(["success", "failure"] as const)( + it.each(["success", "failure", "available"] as const)( "finishes the manual rotation after %s and keeps automatic checks static", async (outcome) => { const idle: DesktopUpdateState = { @@ -115,11 +115,12 @@ describe("SidebarUpdatePill manual checks", () => { expect(button.getAttribute("aria-disabled")).toBe("true"); expect(button.contains(spinner)).toBe(true); + const resultState = outcome === "available" ? nightlyUpdate(RELEASE_NOTES) : idle; await act(async () => { - mocks.update = idle; + mocks.update = resultState; mocks.notify(); - if (outcome === "success") check.resolve({ checked: true, state: idle }); - else check.reject(new Error("Update check failed")); + if (outcome === "failure") check.reject(new Error("Update check failed")); + else check.resolve({ checked: true, state: resultState }); await check.promise.catch(() => undefined); }); expect(button.getAttribute("aria-disabled")).toBe("true"); @@ -141,9 +142,17 @@ describe("SidebarUpdatePill manual checks", () => { ), ); expect(button.hasAttribute("aria-disabled")).toBe(false); + if (outcome === "available") { + expect(button.getAttribute("aria-label")).toBe( + `Update ${resultState.availableVersion} ready to download`, + ); + } await publish({ ...idle, status: "checking" }); expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + await publish(resultState); + expect(button.querySelector('[class*="visible-animate-spin"]')).toBeNull(); + expect(button.querySelector('[class*="desktop-update-check-incoming"]')).toBeNull(); }, ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ac70b405f6f0d..d55041ec7e79b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2427,7 +2427,7 @@ code { } @utility desktop-update-check-incoming { - animation: desktop-update-check-incoming 160ms 120ms ease-out both; + animation: desktop-update-check-incoming 160ms 40ms ease-out both; } @utility desktop-update-restart-ease { From 370572b6863ff2fddbc48be13303199b0b9b240b Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 12:23:41 +0100 Subject: [PATCH 7/9] test: cover simultaneous app and provider updates --- .../SidebarUpdatePill.browser.test.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 67b89886f48e6..329a4b7e1ab20 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -1,16 +1,18 @@ -import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { EnvironmentId, type DesktopBridge, type DesktopUpdateState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { act, render, stubGlobal } from "../../../test/render"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; +import { describeUpdateEverything } from "./updateEverything.logic"; const mocks = vi.hoisted(() => { const listeners = new Set<() => void>(); return { update: null as DesktopUpdateState | null, hasUpdates: false, + everythingView: null as ReturnType | null, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); @@ -36,10 +38,12 @@ vi.mock("../../env", async (importOriginal) => ({ vi.mock("./useUpdateEverything", () => ({ useUpdateEverything: () => ({ hasUpdates: mocks.hasUpdates, - summary: "Update everything", - version: null, - providerCount: 0, - rows: [], + ...(mocks.everythingView ?? { + summary: "Update everything", + version: null, + providerCount: 0, + rows: [], + }), restarts: [], }), })); @@ -72,6 +76,7 @@ const RELEASE_NOTES: DesktopUpdateState["releaseNotes"] = [ function setup(update: DesktopUpdateState | null, { hasUpdates = false } = {}) { mocks.update = update; mocks.hasUpdates = hasUpdates; + mocks.everythingView = null; } function publish(update: DesktopUpdateState | null) { @@ -267,10 +272,23 @@ describe("SidebarUpdatePill downloads", () => { describe("SidebarUpdatePill update everything", () => { it("does not refocus the update button after a pointer cancel", async () => { - setup(null, { hasUpdates: true }); + const update = nightlyUpdate(RELEASE_NOTES); + setup(update, { hasUpdates: true }); + const local = { environmentId: EnvironmentId.make("local"), label: "This machine" }; + mocks.everythingView = describeUpdateEverything({ + localVersion: update.availableVersion, + localMachines: [local], + servers: [], + skipped: [], + providerMachines: [{ ...local, providers: ["Codex", "Claude"] }], + }); await using screen = await render(); - const button = screen.getByRole("button", { name: "Update everything" }); + const button = screen.getByRole("button", { name: "Update this app and 2 providers" }); await userEvent.click(button); + const dialog = page.getByRole("alertdialog"); + await expect.element(dialog.getByText("This app", { exact: false })).toBeVisible(); + await expect.element(dialog.getByText("Codex", { exact: true })).toBeVisible(); + await expect.element(dialog.getByText("Claude", { exact: true })).toBeVisible(); const cancel = page.getByRole("button", { name: "Cancel" }); await expect.element(cancel).toBeVisible(); From aeea78f58cc236e0e54feac996ddb727d0258afa Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sat, 7 Nov 2026 16:54:40 +0100 Subject: [PATCH 8/9] test: keep update-button tests compatible with ES2023 --- .../sidebar/SidebarUpdatePill.browser.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 329a4b7e1ab20..89eac48cbf75b 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -86,6 +86,17 @@ function publish(update: DesktopUpdateState | null) { }); } +function deferredUpdateCheck() { + type Result = Awaited>; + let resolve!: (result: Result) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + return { promise, resolve, reject }; +} + const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill manual checks", () => { @@ -98,7 +109,7 @@ describe("SidebarUpdatePill manual checks", () => { availableVersion: null, }; setup(idle); - const check = Promise.withResolvers>>(); + const check = deferredUpdateCheck(); using _bridge = stubGlobal("desktopBridge", { checkForUpdate: () => check.promise }); await using screen = await render(); const button = screen.getByRole("button", { name: "Check for updates" }).element(); @@ -176,7 +187,7 @@ describe("SidebarUpdatePill manual checks", () => { } return result; }); - const check = Promise.withResolvers>>(); + const check = deferredUpdateCheck(); using _bridge = stubGlobal("desktopBridge", { checkForUpdate: () => check.promise }); await using screen = await render(); const button = screen.getByRole("button", { name: "Check for updates" }).element(); From b6ac9d66e9e7fecae60bf5b9ca792f3c2a86ddb2 Mon Sep 17 00:00:00 2001 From: tenyoru Date: Sun, 8 Nov 2026 19:18:58 +0100 Subject: [PATCH 9/9] refactor(web): take the restart icon's easing from a theme token A global utility that only sets a timing function is what a theme ease token is for, and the lint rule rejects an arbitrary ease value. --- apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx | 2 +- apps/web/src/index.css | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 84fd4133621c1..a53ba78733a2a 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -86,7 +86,7 @@ function DesktopUpdateDownloadIcon({ strokeLinecap="round" strokeLinejoin="round" className={cn( - "desktop-update-restart-ease absolute inset-0 size-5 transition-[opacity,scale,rotate] duration-300 motion-reduce:transition-none", + "absolute inset-0 size-5 transition-[opacity,scale,rotate] duration-300 ease-overshoot motion-reduce:transition-none", complete ? "scale-100 rotate-0 opacity-100 delay-100" : "scale-50 -rotate-90 opacity-0", )} > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d55041ec7e79b..1a6f0dc5cf1c1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -193,6 +193,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --inset-shadow-control-highlight: 0 1px rgb(255 255 255 / 16%); --inset-shadow-control-pressed: 0 1px rgb(0 0 0 / 8%); --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); + /* overshoot settles past its target and back, for an icon that pops into place. */ + --ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1); /* The composer's elevation: a soft drop pulled in under the surface, shared by the composer, its context strip and attached banners. */ --shadow-composer: 0 1px 2px rgb(0 0 0 / 5%); @@ -2430,10 +2432,6 @@ code { animation: desktop-update-check-incoming 160ms 40ms ease-out both; } -@utility desktop-update-restart-ease { - transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); -} - @keyframes desktop-update-check-outgoing { from { transform: rotate(0deg) scale(1);