From 1a0bf4c323d8813e7f317e448569ef8f8aada4cb Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 11:24:19 +0530 Subject: [PATCH 1/4] fix(web): update release notes stay open for interaction --- .../components/sidebar/SidebarUpdatePill.tsx | 260 ++++++++++++++---- 1 file changed, 200 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index fa7103aefb7c..9b1e00038be7 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,5 +1,14 @@ import { TriangleAlertIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { + cloneElement, + type ButtonHTMLAttributes, + type ReactElement, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; @@ -19,6 +28,8 @@ import { } from "../desktopUpdate.logic"; import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; import { Separator } from "../ui/separator"; import { SidebarMenuItem } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -64,36 +75,52 @@ function keyReleaseNoteItems(items: ReadonlyArray) { }); } -function SidebarUpdateReleaseNotesTooltip({ +function SidebarUpdateReleaseNotes({ + action, + isActionPending, + onAction, state, tooltip, }: { + readonly action: ReturnType; + readonly isActionPending: boolean; + readonly onAction: () => void; readonly state: NonNullable>; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { - return <>{tooltip}; - } + const actionLabel = + action === "download" + ? state.status === "error" + ? "Retry download" + : "Download update" + : action === "install" + ? state.status === "error" + ? "Retry install" + : "Restart and install" + : null; return ( -
-
- {state.status === "available" ? ( -
-
- Update ready to download +
+ {state.status === "available" ? ( +
+ + Update ready to download + + {state.availableVersion ? ( +
+ {state.availableVersion}
- {state.availableVersion ? ( -
- {state.availableVersion} -
- ) : null} -
- ) : ( -
{tooltip}
- )} -
-
+ ) : null} +
+ ) : ( + {tooltip} + )} + {actionLabel ? ( + + ) : null} +
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -116,6 +143,111 @@ function SidebarUpdateReleaseNotesTooltip({ ); } +type SidebarUpdateTriggerElement = ReactElement>; + +function SidebarUpdateReleaseNotesPopover({ + action, + isActionPending, + onAction, + state, + tooltip, + trigger, +}: { + readonly action: ReturnType; + readonly isActionPending: boolean; + readonly onAction: () => Promise; + readonly state: NonNullable>; + readonly tooltip: string; + readonly trigger: SidebarUpdateTriggerElement; +}) { + const [open, setOpen] = useState(false); + const popupRef = useRef(null); + const triggerId = useId(); + const pointerTypeRef = useRef(null); + const suppressNextFocusOpenRef = useRef(false); + + const interactiveTrigger = cloneElement(trigger, { + "aria-disabled": undefined, + disabled: false, + onClick: (event) => { + const pointerType = pointerTypeRef.current; + pointerTypeRef.current = null; + // Mouse users keep the pill's one-click action after reading the hover content. Touch and + // pen users open the content first, then use its explicit action button. + if (pointerType && pointerType !== "mouse") { + setOpen(true); + ( + event as typeof event & { + preventBaseUIHandler?: () => void; + } + ).preventBaseUIHandler?.(); + return; + } + void onAction(); + }, + onFocus: () => { + if (suppressNextFocusOpenRef.current) { + suppressNextFocusOpenRef.current = false; + return; + } + setOpen(true); + }, + onKeyDown: () => { + pointerTypeRef.current = null; + }, + onPointerDown: (event) => { + pointerTypeRef.current = event.pointerType; + }, + onPointerCancel: () => { + pointerTypeRef.current = null; + }, + }); + + return ( + + { + if ( + !nextOpen && + eventDetails.reason === "escape-key" && + popupRef.current?.contains(document.activeElement) + ) { + suppressNextFocusOpenRef.current = true; + } + setOpen(nextOpen); + }} + open={open} + triggerId={triggerId} + > + + + { + void onAction(); + }} + state={state} + tooltip={tooltip} + /> + + + + ); +} + export function SidebarUpdateArchitectureWarning() { return isElectron ? : null; } @@ -304,44 +436,56 @@ function SidebarUpdateControl() { ); }, [prefersReducedMotion, state?.status]); + const showReleaseNotes = Boolean( + showUpdateDetails && state && state.channel === "nightly" && state.releaseNotes.length > 0, + ); + + const trigger = ( + + ); + + // Release notes are long enough to scroll and to read at your own pace, so they hover as a + // popover: its popup keeps pointer events, and its viewport scrolls within the space it has. + if (showReleaseNotes && state) { + return ( + + ); + } + return ( - - - - } - /> + 0 - ? // pointer-events-auto overrides the positioner's pointer-events-none so the - // release notes stay open (and scrollable) when the cursor moves into them. - "pointer-events-auto max-w-none text-balance" - : undefined - } side="top" style={ showUpdateDetails @@ -354,11 +498,7 @@ function SidebarUpdateControl() { } variant={showUpdateDetails ? "glass" : "default"} > - {showUpdateDetails && state ? ( - - ) : ( - tooltip - )} + {tooltip} From a30334ec82dbdec3d48a86c30575dbf8a862f06a Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 11:46:48 +0530 Subject: [PATCH 2/4] fix(web): preserve disabled update pill semantics --- .../sidebar/SidebarUpdatePill.test.tsx | 189 ++++++++++++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 20 +- 2 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx new file mode 100644 index 000000000000..a47c785f1117 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx @@ -0,0 +1,189 @@ +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const testState = vi.hoisted(() => ({ + desktopUpdate: null as DesktopUpdateState | null, + downloadUpdate: vi.fn<() => Promise>(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: () => undefined, + useId: () => "sidebar-update-trigger", + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../env", () => ({ isElectron: true })); +vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => false })); +vi.mock("../../state/desktopUpdate", () => ({ + useDesktopUpdateState: () => testState.desktopUpdate, +})); + +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +const availableState: DesktopUpdateState = { + enabled: true, + status: "available", + channel: "nightly", + currentVersion: "1.0.0-nightly.1", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: "1.0.0-nightly.2", + downloadedVersion: null, + releaseNotes: [{ version: "1.0.0-nightly.2", items: ["fix: keep notes interactive"] }], + downloadPercent: null, + checkedAt: "2026-08-22T00:00:00.000Z", + message: null, + errorContext: null, + canRetry: false, +}; + +type TestElement = ReactElement>; + +function invokeComponent(element: TestElement): TestElement { + if (typeof element.type !== "function") { + throw new Error("Expected a function component"); + } + const component = element.type as unknown as (props: Record) => TestElement; + return component(element.props); +} + +function renderPopover() { + hooks.beginRender(); + const control = SidebarUpdatePill() as TestElement; + const popover = invokeComponent(control); + const output = invokeComponent(popover); + const trigger = visitElements( + output, + (element) => element.type === "button" && typeof element.props["aria-label"] === "string", + ); + const root = visitElements( + output, + (element) => + typeof element.props.open === "boolean" && typeof element.props.onOpenChange === "function", + ); + + if (!trigger || !root) throw new Error("Expected update popover and trigger"); + return { output, root, trigger }; +} + +function activateTrigger(trigger: TestElement, pointerType: "mouse" | "pen" | "touch") { + const preventBaseUIHandler = vi.fn(); + const onPointerDown = trigger.props.onPointerDown as + | ((event: { pointerType: string }) => void) + | undefined; + const onClick = trigger.props.onClick as + | ((event: { preventBaseUIHandler: () => void }) => void) + | undefined; + + onPointerDown?.({ pointerType }); + onClick?.({ preventBaseUIHandler }); + return preventBaseUIHandler; +} + +function installDesktopBridge() { + vi.stubGlobal("window", { + desktopBridge: { + downloadUpdate: testState.downloadUpdate, + }, + }); +} + +describe("SidebarUpdatePill release notes popover", () => { + beforeEach(() => { + hooks.reset(); + testState.desktopUpdate = availableState; + testState.downloadUpdate.mockReset(); + testState.downloadUpdate.mockResolvedValue({ + accepted: true, + completed: false, + state: availableState, + }); + installDesktopBridge(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("runs the update action on a mouse click", () => { + const { trigger } = renderPopover(); + + const preventBaseUIHandler = activateTrigger(trigger, "mouse"); + + expect(testState.downloadUpdate).toHaveBeenCalledTimes(1); + expect(preventBaseUIHandler).not.toHaveBeenCalled(); + }); + + it.each(["touch", "pen"] as const)( + "opens release notes before acting for a %s click", + (pointerType) => { + const { trigger } = renderPopover(); + + const preventBaseUIHandler = activateTrigger(trigger, pointerType); + const rerendered = renderPopover(); + + expect(testState.downloadUpdate).not.toHaveBeenCalled(); + expect(preventBaseUIHandler).toHaveBeenCalledTimes(1); + expect(rerendered.root.props.open).toBe(true); + }, + ); + + it("keeps the downloading trigger focusable but marks its action disabled", () => { + testState.desktopUpdate = { + ...availableState, + status: "downloading", + downloadPercent: 42, + }; + + const { trigger } = renderPopover(); + activateTrigger(trigger, "mouse"); + + expect(trigger.props.disabled).toBe(false); + expect(trigger.props["aria-disabled"]).toBe(true); + expect(trigger.props.className).toContain("cursor-not-allowed"); + expect(trigger.props.className).not.toContain("cursor-pointer"); + expect(trigger.props.className).not.toContain("hover:bg-update/12"); + expect(testState.downloadUpdate).not.toHaveBeenCalled(); + }); + + it("marks the trigger and popover action disabled while an action is pending", () => { + testState.downloadUpdate.mockReturnValue(new Promise(() => undefined)); + const { trigger } = renderPopover(); + activateTrigger(trigger, "mouse"); + + const rerendered = renderPopover(); + const releaseNotes = visitElements( + rerendered.output, + (element) => element.props.state === availableState && element.props.isActionPending === true, + ); + if (!releaseNotes) throw new Error("Expected release notes content"); + const actionButton = visitElements( + invokeComponent(releaseNotes), + (element) => element.props.children === "Download update", + ); + activateTrigger(rerendered.trigger, "mouse"); + + expect(rerendered.trigger.props.disabled).toBe(false); + expect(rerendered.trigger.props["aria-disabled"]).toBe(true); + expect(rerendered.trigger.props.className).toContain("cursor-not-allowed"); + expect(actionButton?.props.disabled).toBe(true); + expect(testState.downloadUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 9b1e00038be7..600439546266 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -167,7 +167,6 @@ function SidebarUpdateReleaseNotesPopover({ const suppressNextFocusOpenRef = useRef(false); const interactiveTrigger = cloneElement(trigger, { - "aria-disabled": undefined, disabled: false, onClick: (event) => { const pointerType = pointerTypeRef.current; @@ -439,18 +438,27 @@ function SidebarUpdateControl() { const showReleaseNotes = Boolean( showUpdateDetails && state && state.channel === "nightly" && state.releaseNotes.length > 0, ); + const isTriggerActionDisabled = disabled || isActionPending; const trigger = (