diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 9b8b388044f55..a53ba78733a2a 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,83 +19,80 @@ function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { return Math.min(100, Math.max(0, percent)); } -export function shouldShowDesktopUpdateCheckIcon({ - isAnimationLatched, - isChecking, - prefersReducedMotion, +function DesktopUpdateDownloadIcon({ + percent, + complete, }: { - 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); + 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 ( - - - - + + + + + + + + + ); @@ -103,26 +101,50 @@ 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 === "everything") return ; + const statusIcon = + 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 03bb2c72849e2..89eac48cbf75b 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 { 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 } from "../../../test/render"; +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) { @@ -81,8 +86,123 @@ 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", () => { + it.each(["success", "failure", "available"] 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 = deferredUpdateCheck(); + 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); + + const resultState = outcome === "available" ? nightlyUpdate(RELEASE_NOTES) : idle; + await act(async () => { + mocks.update = resultState; + mocks.notify(); + 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"); + expect(button.contains(spinner)).toBe(true); + + await act(() => + spinner!.dispatchEvent(new AnimationEvent("animationiteration", { bubbles: true })), + ); + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(button.querySelector('[class*="visible-animate-spin"]')).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); + 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(); + }, + ); + + 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 = deferredUpdateCheck(); + 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)); @@ -116,12 +236,70 @@ describe("SidebarUpdatePill release notes", () => { }); }); +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 }); + 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(); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 3c18b2c7a927b..6be6e2a4660d6 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -36,8 +36,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { DesktopUpdateStatusIcon, type DesktopUpdateStatusIconState, - shouldContinueDesktopUpdateCheckAnimation, - shouldShowDesktopUpdateCheckIcon, } from "./DesktopUpdateStatusIcon"; import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; import { SidebarUpdateCapsule } from "./SidebarUpdateCapsule"; @@ -201,33 +199,23 @@ 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" | "returning" + >("idle"); const [providerCheck, setProviderCheck] = useState<"button" | "dialog" | null>(null); const isCheckingProviders = providerCheck === "button"; 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 + prefersReducedMotion && + (manualCheckStatus === "finishing" || manualCheckStatus === "returning") ) { - setLatchInputs({ prefersReducedMotion, updateStatus }); - if (prefersReducedMotion) { - setIsCheckAnimationLatched(false); - } else if (updateStatus === "checking") { - setIsCheckAnimationLatched(true); - } + setManualCheckStatus("idle"); } - const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; const localUpdateVersion = @@ -237,11 +225,9 @@ function SidebarUpdateControl() { const runsEverything = everything.hasUpdates && !isDownloading; const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; - const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ - isAnimationLatched: isCheckAnimationLatched, - isChecking: state?.status === "checking" || isCheckingProviders, - prefersReducedMotion, - }); + const showCheckIcon = manualCheckStatus === "pending" || manualCheckStatus === "finishing"; + const isCheckingForUpdates = + state?.status === "checking" || isCheckingProviders || manualCheckStatus !== "idle"; const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, showsEverything: runsEverything, @@ -250,17 +236,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 @@ -328,10 +316,7 @@ function SidebarUpdateControl() { return; } - if (!prefersReducedMotion) { - setIsCheckAnimationLatched(true); - setCheckAnimationKey((key) => key + 1); - } + setManualCheckStatus("pending"); const appCheck = bridge .checkForUpdate() .then((result) => { @@ -358,7 +343,10 @@ function SidebarUpdateControl() { }); setProviderCheck("button"); const providersChecked = checkProviderUpdates().finally(() => setProviderCheck(null)); - void Promise.allSettled([appCheck, providersChecked]).then(() => setIsActionPending(false)); + void Promise.allSettled([appCheck, providersChecked]).then(() => { + setManualCheckStatus("finishing"); + setIsActionPending(false); + }); }; const runEverything = async () => { @@ -391,23 +379,17 @@ function SidebarUpdateControl() { setIsActionPending(false); }; - const handleCheckAnimationIteration = () => { - setIsCheckAnimationLatched( - shouldContinueDesktopUpdateCheckAnimation({ - isChecking: state?.status === "checking" || isCheckingProviders, - prefersReducedMotion, - }), - ); - }; - if (!isElectron && !runsEverything && !isRunningEverything) return null; const statusIcon = ( { + if (manualCheckStatus === "finishing") setManualCheckStatus("returning"); + }} + onCheckReturnComplete={() => setManualCheckStatus("idle")} status={iconStatus} /> ); @@ -468,10 +450,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", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b24265d02197e..30788bca5b390 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%); @@ -2433,6 +2435,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 40ms 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. */