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. */