From 55dff40c7838433d74ff1f09befdc0d4933e16c2 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 13:10:38 +0530 Subject: [PATCH 1/4] fix(web): keep update changelogs open without changing pill actions --- .../sidebar/SidebarUpdatePill.test.tsx | 170 ++++++++++++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 143 ++++++++------- 2 files changed, 251 insertions(+), 62 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..6ef7f80523f6 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx @@ -0,0 +1,170 @@ +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, + 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 renderControl() { + hooks.beginRender(); + return invokeComponent(SidebarUpdatePill() as TestElement); +} + +function findTrigger(output: TestElement) { + const trigger = visitElements( + output, + (element) => element.type === "button" && typeof element.props["aria-label"] === "string", + ); + if (!trigger) throw new Error("Expected update trigger"); + return trigger; +} + +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("downloads from the sidebar trigger without toggling the popover", () => { + const output = renderControl(); + const root = visitElements( + output, + (element) => + typeof element.props.open === "boolean" && typeof element.props.onOpenChange === "function", + ); + if (!root) throw new Error("Expected update popover"); + const onOpenChange = root.props.onOpenChange as (open: boolean) => void; + onOpenChange(true); + + const openedOutput = renderControl(); + const trigger = findTrigger(openedOutput); + const preventBaseUIHandler = vi.fn(); + const onClick = trigger.props.onClick as + | ((event: { preventBaseUIHandler: () => void }) => void) + | undefined; + + onClick?.({ preventBaseUIHandler }); + + const closedOutput = renderControl(); + const closedRoot = visitElements(closedOutput, (element) => element.props.open === false); + + expect(preventBaseUIHandler).toHaveBeenCalledTimes(1); + expect(testState.downloadUpdate).toHaveBeenCalledTimes(1); + expect(closedRoot).not.toBeNull(); + }); + + it("uses a hover popover containing only the changelog", () => { + const output = renderControl(); + const popoverTrigger = visitElements(output, (element) => element.props.openOnHover === true); + const popup = visitElements(output, (element) => element.props.initialFocus === false); + const releaseNotes = visitElements(output, (element) => element.props.state === availableState); + + if (!releaseNotes) throw new Error("Expected release notes content"); + const releaseNotesOutput = invokeComponent(releaseNotes); + const actionButton = visitElements( + releaseNotesOutput, + (element) => + element.type === "button" && + (element.props.children === "Download update" || + element.props.children === "Restart and install"), + ); + + expect(popoverTrigger?.props.delay).toBe(150); + expect(popoverTrigger?.props.closeDelay).toBe(120); + expect(popup?.props.className).toContain("max-w-[min(24rem"); + expect(popup?.props.tooltipStyle).toBeUndefined(); + expect(popup?.props.style).toBeUndefined(); + expect(popup?.props.viewportClassName).toContain("max-h-"); + expect(actionButton).toBeNull(); + }); + + it.each([ + { channel: "latest" as const, releaseNotes: availableState.releaseNotes }, + { channel: "nightly" as const, releaseNotes: [] }, + ])("keeps the existing tooltip when release notes are unavailable", (stateOverride) => { + testState.desktopUpdate = { ...availableState, ...stateOverride }; + + const output = renderControl(); + const popoverTrigger = visitElements(output, (element) => element.props.openOnHover === true); + + expect(popoverTrigger).toBeNull(); + expect(findTrigger(output)).toBeDefined(); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index fa7103aefb7c..45c2cecb53a2 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -19,6 +19,7 @@ import { } from "../desktopUpdate.logic"; import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +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,19 +65,15 @@ function keyReleaseNoteItems(items: ReadonlyArray) { }); } -function SidebarUpdateReleaseNotesTooltip({ +function SidebarUpdateReleaseNotes({ state, tooltip, }: { readonly state: NonNullable>; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { - return <>{tooltip}; - } - return ( -
+
{state.status === "available" ? (
@@ -93,7 +90,7 @@ function SidebarUpdateReleaseNotesTooltip({
{tooltip}
)}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -143,6 +140,7 @@ export function SidebarUpdatePill() { function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); + const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); @@ -304,63 +302,84 @@ function SidebarUpdateControl() { ); }, [prefersReducedMotion, state?.status]); + const showReleaseNotesPopover = + showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; + const trigger = ( + + ); + 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 - ? { - background: - "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", - borderColor: "var(--update-foreground)", - } - : undefined - } - variant={showUpdateDetails ? "glass" : "default"} - > - {showUpdateDetails && state ? ( - - ) : ( - tooltip - )} - - + {showReleaseNotesPopover ? ( + + + + {tooltip} + + + + ) : ( + + + + {tooltip} + + + )} ); } From 301fb4185ec63f53ae36e542010403c65bed50d3 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 13:40:03 +0530 Subject: [PATCH 2/4] fix(web): open update notes on keyboard focus --- .../sidebar/SidebarUpdatePill.test.tsx | 22 +++++++++++++++++++ .../components/sidebar/SidebarUpdatePill.tsx | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx index 6ef7f80523f6..69adc3191dba 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx @@ -155,6 +155,28 @@ describe("SidebarUpdatePill release notes popover", () => { expect(actionButton).toBeNull(); }); + it("opens the release notes on focus and closes them on blur", () => { + const output = renderControl(); + const trigger = findTrigger(output); + const onFocus = trigger.props.onFocus as (() => void) | undefined; + + onFocus?.(); + + const focusedOutput = renderControl(); + const focusedRoot = visitElements(focusedOutput, (element) => element.props.open === true); + const focusedTrigger = findTrigger(focusedOutput); + const onBlur = focusedTrigger.props.onBlur as (() => void) | undefined; + + expect(focusedRoot).not.toBeNull(); + + onBlur?.(); + + const blurredOutput = renderControl(); + const blurredRoot = visitElements(blurredOutput, (element) => element.props.open === false); + + expect(blurredRoot).not.toBeNull(); + }); + it.each([ { channel: "latest" as const, releaseNotes: availableState.releaseNotes }, { channel: "nightly" as const, releaseNotes: [] }, diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 45c2cecb53a2..a416a737854c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -317,6 +317,10 @@ function SidebarUpdateControl() { : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", disabled && !showUpdateIconState && "opacity-60", )} + onBlur={() => setReleaseNotesOpen(false)} + onFocus={() => { + if (showReleaseNotesPopover) setReleaseNotesOpen(true); + }} onClick={(event) => { ( event as typeof event & { From 75ec83931e9fb0e39a1b776228bb0b63c99d4352 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 15:02:34 +0530 Subject: [PATCH 3/4] fix(web): keep update popover open during interaction --- .../sidebar/SidebarUpdatePill.test.tsx | 43 +++++++++++++++++-- .../components/sidebar/SidebarUpdatePill.tsx | 10 ++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx index 69adc3191dba..7a203ae36b4d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx @@ -155,7 +155,7 @@ describe("SidebarUpdatePill release notes popover", () => { expect(actionButton).toBeNull(); }); - it("opens the release notes on focus and closes them on blur", () => { + it("closes focused release notes only after focus and pointer leave the popover", () => { const output = renderControl(); const trigger = findTrigger(output); const onFocus = trigger.props.onFocus as (() => void) | undefined; @@ -165,11 +165,48 @@ describe("SidebarUpdatePill release notes popover", () => { const focusedOutput = renderControl(); const focusedRoot = visitElements(focusedOutput, (element) => element.props.open === true); const focusedTrigger = findTrigger(focusedOutput); - const onBlur = focusedTrigger.props.onBlur as (() => void) | undefined; + const onBlur = focusedTrigger.props.onBlur as + | ((event: { + currentTarget: { getAttribute: (name: string) => string | null }; + relatedTarget: EventTarget | null; + }) => void) + | undefined; + const matches = vi.fn(() => true); + const contains = vi.fn(() => false); + const popupId = "release-notes-popover"; + vi.stubGlobal("document", { + getElementById: vi.fn(() => ({ contains, matches })), + }); + const blurEvent = { + currentTarget: { + getAttribute: (name: string) => (name === "aria-controls" ? popupId : null), + }, + relatedTarget: {} as EventTarget, + }; expect(focusedRoot).not.toBeNull(); - onBlur?.(); + onBlur?.(blurEvent); + + const hoveredOutput = renderControl(); + const hoveredRoot = visitElements(hoveredOutput, (element) => element.props.open === true); + + expect(hoveredRoot).not.toBeNull(); + + matches.mockReturnValue(false); + contains.mockReturnValue(true); + onBlur?.(blurEvent); + + const containedFocusOutput = renderControl(); + const containedFocusRoot = visitElements( + containedFocusOutput, + (element) => element.props.open === true, + ); + + expect(containedFocusRoot).not.toBeNull(); + + contains.mockReturnValue(false); + onBlur?.(blurEvent); const blurredOutput = renderControl(); const blurredRoot = visitElements(blurredOutput, (element) => element.props.open === false); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index a416a737854c..03f1ed3fa123 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -317,7 +317,15 @@ function SidebarUpdateControl() { : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", disabled && !showUpdateIconState && "opacity-60", )} - onBlur={() => setReleaseNotesOpen(false)} + onBlur={(event) => { + const popupId = event.currentTarget.getAttribute("aria-controls"); + const popup = popupId ? document.getElementById(popupId) : null; + const focusMovedIntoPopup = + event.relatedTarget !== null && popup?.contains(event.relatedTarget as Node); + + if (popup?.matches(":hover") || focusMovedIntoPopup) return; + setReleaseNotesOpen(false); + }} onFocus={() => { if (showReleaseNotesPopover) setReleaseNotesOpen(true); }} From da6109b60054a3abc7369aaac19a60a91806f7fd Mon Sep 17 00:00:00 2001 From: Rakshith Bhat Date: Sat, 22 Aug 2026 21:18:07 +0530 Subject: [PATCH 4/4] fix(web): reset update popover when notes disappear --- .../sidebar/SidebarUpdatePill.test.tsx | 28 ++++++ .../components/sidebar/SidebarUpdatePill.tsx | 96 ++++++++++++------- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx index 7a203ae36b4d..234aac269cd3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx @@ -64,10 +64,27 @@ function invokeComponent(element: TestElement): TestElement { } function renderControl() { + hooks.beginRender(); + const output = invokeComponent(SidebarUpdatePill() as TestElement); + const releaseNotesPopover = findReleaseNotesPopover(output); + return releaseNotesPopover ? invokeComponent(releaseNotesPopover) : output; +} + +function renderControlElement() { hooks.beginRender(); return invokeComponent(SidebarUpdatePill() as TestElement); } +function findReleaseNotesPopover(output: TestElement) { + return visitElements( + output, + (element) => + typeof element.type === "function" && + typeof element.props.renderTrigger === "function" && + element.props.state === testState.desktopUpdate, + ); +} + function findTrigger(output: TestElement) { const trigger = visitElements( output, @@ -155,6 +172,17 @@ describe("SidebarUpdatePill release notes popover", () => { expect(actionButton).toBeNull(); }); + it("mounts the stateful popover only while release notes are eligible", () => { + const eligibleOutput = renderControlElement(); + + expect(findReleaseNotesPopover(eligibleOutput)).not.toBeNull(); + + testState.desktopUpdate = { ...availableState, status: "checking", releaseNotes: [] }; + const ineligibleOutput = renderControlElement(); + + expect(findReleaseNotesPopover(ineligibleOutput)).toBeNull(); + }); + it("closes focused release notes only after focus and pointer leave the popover", () => { const output = renderControl(); const trigger = findTrigger(output); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 03f1ed3fa123..07330c27c545 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,5 +1,5 @@ import { TriangleAlertIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, type ReactElement } from "react"; import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; @@ -113,6 +113,42 @@ function SidebarUpdateReleaseNotes({ ); } +function SidebarUpdateReleaseNotesPopover({ + disabled, + renderTrigger, + state, + tooltip, +}: { + readonly disabled: boolean; + readonly renderTrigger: (setOpen: (open: boolean) => void) => ReactElement; + readonly state: NonNullable>; + readonly tooltip: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + {tooltip} + + + + ); +} + export function SidebarUpdateArchitectureWarning() { return isElectron ? : null; } @@ -140,7 +176,6 @@ export function SidebarUpdatePill() { function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); - const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); @@ -304,7 +339,7 @@ function SidebarUpdateControl() { const showReleaseNotesPopover = showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; - const trigger = ( + const renderTrigger = (setReleaseNotesOpen?: (open: boolean) => void) => (