From 8d6ca70d3b9c25d49f7b79734a6918da0691b1a7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:39:40 +0000 Subject: [PATCH 1/2] feat: move titlebar update actions into about dialog --- src/browser/App.tsx | 27 ++- src/browser/components/About/AboutDialog.tsx | 224 ++++++++++++++++++ src/browser/components/TitleBar.tsx | 187 +++------------ src/browser/contexts/AboutDialogContext.tsx | 31 +++ tests/ui/aboutDialog.integration.test.ts | 229 +++++++++++++++++++ 5 files changed, 533 insertions(+), 165 deletions(-) create mode 100644 src/browser/components/About/AboutDialog.tsx create mode 100644 src/browser/contexts/AboutDialogContext.tsx create mode 100644 tests/ui/aboutDialog.integration.test.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 621da4a20a..ded73f790e 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -63,7 +63,9 @@ import { Button } from "./components/ui/button"; import { ProjectPage } from "@/browser/components/ProjectPage"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; +import { AboutDialogProvider } from "./contexts/AboutDialogContext"; import { SettingsModal } from "./components/Settings/SettingsModal"; +import { AboutDialog } from "./components/About/AboutDialog"; import { MuxGatewaySessionExpiredDialog } from "./components/MuxGatewaySessionExpiredDialog"; import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider"; import { TutorialProvider } from "./contexts/TutorialContext"; @@ -1043,6 +1045,7 @@ function AppInner() { }} /> + @@ -1056,17 +1059,19 @@ function App() { - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/browser/components/About/AboutDialog.tsx b/src/browser/components/About/AboutDialog.tsx new file mode 100644 index 0000000000..d0bcf2dbf9 --- /dev/null +++ b/src/browser/components/About/AboutDialog.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from "react"; +import { Download, Loader2, RefreshCw } from "lucide-react"; +import { VERSION } from "@/version"; +import type { UpdateStatus } from "@/common/orpc/types"; +import MuxLogoDark from "@/browser/assets/logos/mux-logo-dark.svg?react"; +import MuxLogoLight from "@/browser/assets/logos/mux-logo-light.svg?react"; +import { useTheme } from "@/browser/contexts/ThemeContext"; +import { useAPI } from "@/browser/contexts/API"; +import { useAboutDialog } from "@/browser/contexts/AboutDialogContext"; +import { Button } from "@/browser/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/browser/components/ui/dialog"; + +interface VersionRecord { + buildTime?: unknown; + git?: unknown; + git_describe?: unknown; +} + +function formatExtendedTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }); +} + +function parseVersionInfo(version: unknown): { gitDescribe: string; buildTime: string } { + if (typeof version !== "object" || version === null) { + return { + gitDescribe: "dev", + buildTime: "Unknown build time", + }; + } + + const versionRecord = version as VersionRecord; + const gitDescribe = + typeof versionRecord.git_describe === "string" + ? versionRecord.git_describe + : typeof versionRecord.git === "string" + ? versionRecord.git + : "dev"; + + return { + gitDescribe, + buildTime: + typeof versionRecord.buildTime === "string" + ? formatExtendedTimestamp(versionRecord.buildTime) + : "Unknown build time", + }; +} + +export function AboutDialog() { + const { isOpen, close } = useAboutDialog(); + const { api } = useAPI(); + const { theme } = useTheme(); + const MuxLogo = theme === "dark" || theme.endsWith("-dark") ? MuxLogoDark : MuxLogoLight; + const { gitDescribe, buildTime } = parseVersionInfo(VERSION satisfies unknown); + const [updateStatus, setUpdateStatus] = useState({ type: "idle" }); + + const isDesktop = typeof window !== "undefined" && Boolean(window.api); + + useEffect(() => { + if (!isOpen || !isDesktop || !api) { + return; + } + + const controller = new AbortController(); + const { signal } = controller; + + (async () => { + try { + const iterator = await api.update.onStatus(undefined, { signal }); + for await (const status of iterator) { + if (signal.aborted) { + break; + } + setUpdateStatus(status); + } + } catch (error) { + if (!signal.aborted) { + console.error("Update status stream error:", error); + } + } + })(); + + return () => { + controller.abort(); + }; + }, [api, isDesktop, isOpen]); + + const isChecking = updateStatus.type === "checking" || updateStatus.type === "downloading"; + + const handleCheckForUpdates = () => { + api?.update.check(undefined).catch(console.error); + }; + + const handleDownload = () => { + api?.update.download(undefined).catch(console.error); + }; + + const handleInstall = () => { + api?.update.install(undefined).catch(console.error); + }; + + return ( + !nextOpen && close()}> + + About Mux + +
+ +
+
Mux
+
Parallel agent workflows
+
+
+ +
+
+ Version + {gitDescribe} +
+
+ Built + {buildTime} +
+
+ +
+
Updates
+ + {!isDesktop ? ( +
+ Desktop updates are available in the Electron app only. +
+ ) : ( + <> + + + {updateStatus.type === "checking" && ( +
Checking for updates…
+ )} + + {updateStatus.type === "available" && ( +
+
+ Update available: {updateStatus.info.version} +
+ +
+ )} + + {updateStatus.type === "downloading" && ( +
+ Downloading update: {updateStatus.percent}% +
+ )} + + {updateStatus.type === "downloaded" && ( +
+
+ Ready to install: {updateStatus.info.version} +
+ +
+ )} + + {updateStatus.type === "up-to-date" && ( +
Mux is up to date.
+ )} + + {updateStatus.type === "idle" && ( +
Run a manual check to look for updates.
+ )} + + {updateStatus.type === "error" && ( +
+
+ Update check failed: {updateStatus.message} +
+ +
+ )} + + )} + + + View all releases + +
+
+
+ ); +} diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index b0b448bcfe..84c4d592bd 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { cn } from "@/common/lib/utils"; import { VERSION } from "@/version"; import { SettingsButton } from "./SettingsButton"; @@ -8,6 +8,7 @@ import type { UpdateStatus } from "@/common/orpc/types"; import { Download, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; import { useAPI } from "@/browser/contexts/API"; +import { useAboutDialog } from "@/browser/contexts/AboutDialogContext"; import { usePolicy } from "@/browser/contexts/PolicyContext"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useGateway } from "@/browser/hooks/useGatewayModels"; @@ -21,56 +22,35 @@ import { DESKTOP_TITLEBAR_HEIGHT_CLASS, } from "@/browser/hooks/useDesktopTitlebar"; -// Update check intervals +// Update check interval const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours -const UPDATE_CHECK_HOVER_COOLDOWN_MS = 60 * 1000; // 1 minute -interface VersionMetadata { - buildTime: string; +interface VersionRecord { + git?: unknown; git_describe?: unknown; } -function hasBuildInfo(value: unknown): value is VersionMetadata { - if (typeof value !== "object" || value === null) { - return false; +function getGitDescribe(version: unknown): string | undefined { + if (typeof version !== "object" || version === null) { + return undefined; } - const candidate = value as Record; - return typeof candidate.buildTime === "string"; -} - -function formatExtendedTimestamp(isoDate: string): string { - const date = new Date(isoDate); - return date.toLocaleString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - timeZoneName: "short", - }); -} + const versionRecord = version as VersionRecord; -function parseBuildInfo(version: unknown) { - if (hasBuildInfo(version)) { - const { buildTime, git_describe } = version; - const gitDescribe = typeof git_describe === "string" ? git_describe : undefined; + if (typeof versionRecord.git_describe === "string") { + return versionRecord.git_describe; + } - return { - extendedTimestamp: formatExtendedTimestamp(buildTime), - gitDescribe, - }; + if (typeof versionRecord.git === "string") { + return versionRecord.git; } - return { - extendedTimestamp: "Unknown build time", - gitDescribe: undefined, - }; + return undefined; } export function TitleBar() { const { api } = useAPI(); + const { open: openAboutDialog } = useAboutDialog(); const policyState = usePolicy(); const policyEnforced = policyState.status.state === "enforced"; const { open: openSettings } = useSettings(); @@ -81,10 +61,8 @@ export function TitleBar() { refresh: refreshMuxGatewayAccountStatus, } = useMuxGatewayAccountStatus(); - const { extendedTimestamp, gitDescribe } = parseBuildInfo(VERSION satisfies unknown); + const gitDescribe = getGitDescribe(VERSION satisfies unknown); const [updateStatus, setUpdateStatus] = useState({ type: "idle" }); - const [isCheckingOnHover, setIsCheckingOnHover] = useState(false); - const lastHoverCheckTime = useRef(0); useEffect(() => { // Skip update checks in browser mode - app updates only apply to Electron @@ -92,7 +70,10 @@ export function TitleBar() { return; } - if (!api) return; + if (!api) { + return; + } + const controller = new AbortController(); const { signal } = controller; @@ -100,9 +81,10 @@ export function TitleBar() { try { const iterator = await api.update.onStatus(undefined, { signal }); for await (const status of iterator) { - if (signal.aborted) break; + if (signal.aborted) { + break; + } setUpdateStatus(status); - setIsCheckingOnHover(false); // Clear checking state when status updates } } catch (error) { if (!signal.aborted) { @@ -125,100 +107,6 @@ export function TitleBar() { }; }, [api]); - const handleIndicatorHover = () => { - // Skip update checks in browser mode - app updates only apply to Electron - if (!window.api) { - return; - } - - // Debounce: Only check once per cooldown period on hover - const now = Date.now(); - - if (now - lastHoverCheckTime.current < UPDATE_CHECK_HOVER_COOLDOWN_MS) { - return; // Too soon since last hover check - } - - // Only trigger check if idle/up-to-date/error and not already checking. - // Including "error" ensures the user can retry after a failure by hovering - // again, instead of being stuck until the 4-hour interval or a restart. - if ( - (updateStatus.type === "idle" || - updateStatus.type === "up-to-date" || - updateStatus.type === "error") && - !isCheckingOnHover - ) { - lastHoverCheckTime.current = now; - setIsCheckingOnHover(true); - api?.update.check().catch((error) => { - console.error("Update check failed:", error); - setIsCheckingOnHover(false); - }); - } - }; - - const handleUpdateClick = () => { - // Skip in browser mode - app updates only apply to Electron - if (!window.api) { - return; - } - - if (updateStatus.type === "available") { - api?.update.download().catch(console.error); - } else if (updateStatus.type === "downloaded") { - void api?.update.install(); - } - }; - - const getUpdateTooltip = () => { - const currentVersion = gitDescribe ?? "dev"; - const lines: React.ReactNode[] = [`Current: ${currentVersion}`, `Built: ${extendedTimestamp}`]; - - if (!window.api) { - lines.push("Desktop updates are available in the Electron app only."); - } else if (isCheckingOnHover || updateStatus.type === "checking") { - lines.push("Checking for updates..."); - } else { - switch (updateStatus.type) { - case "available": - lines.push(`Update available: ${updateStatus.info.version}`, "Click to download."); - break; - case "downloading": - lines.push(`Downloading update: ${updateStatus.percent}%`); - break; - case "downloaded": - lines.push(`Update ready: ${updateStatus.info.version}`, "Click to install and restart."); - break; - case "idle": - lines.push("Hover to check for updates"); - break; - case "up-to-date": - lines.push("Up to date"); - break; - case "error": - lines.push("Update check failed", updateStatus.message); - break; - } - } - - // Always add releases link as defense-in-depth - lines.push( - - View all releases - - ); - - return ( - <> - {lines.map((line, i) => ( - - {i > 0 &&
} - {line} -
- ))} - - ); - }; - const updateBadgeIcon = (() => { if (updateStatus.type === "available") { return ; @@ -228,20 +116,13 @@ export function TitleBar() { return ; } - if ( - updateStatus.type === "downloading" || - updateStatus.type === "checking" || - isCheckingOnHover - ) { + if (updateStatus.type === "downloading" || updateStatus.type === "checking") { return ; } return null; })(); - const isUpdateActionable = - updateStatus.type === "available" || updateStatus.type === "downloaded"; - // In desktop mode, add left padding for macOS traffic lights const leftInset = getTitlebarLeftInset(); const isDesktop = isDesktopMode(); @@ -266,20 +147,20 @@ export function TitleBar() { > -
0 ? "text-[10px]" : "text-xs" )} > @@ -290,11 +171,9 @@ export function TitleBar() { {updateBadgeIcon}
)} -
+
- - {getUpdateTooltip()} - + Click for more details
diff --git a/src/browser/contexts/AboutDialogContext.tsx b/src/browser/contexts/AboutDialogContext.tsx new file mode 100644 index 0000000000..f42fba9517 --- /dev/null +++ b/src/browser/contexts/AboutDialogContext.tsx @@ -0,0 +1,31 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; + +interface AboutDialogContextValue { + isOpen: boolean; + open: () => void; + close: () => void; +} + +const AboutDialogContext = createContext(null); + +export function useAboutDialog(): AboutDialogContextValue { + const ctx = useContext(AboutDialogContext); + if (!ctx) throw new Error("useAboutDialog must be used within AboutDialogProvider"); + return ctx; +} + +export function AboutDialogProvider(props: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(true), + close: () => setIsOpen(false), + }} + > + {props.children} + + ); +} diff --git a/tests/ui/aboutDialog.integration.test.ts b/tests/ui/aboutDialog.integration.test.ts new file mode 100644 index 0000000000..3d9ee43d7b --- /dev/null +++ b/tests/ui/aboutDialog.integration.test.ts @@ -0,0 +1,229 @@ +import "./dom"; + +import { fireEvent, waitFor, within } from "@testing-library/react"; + +import { installDom } from "./dom"; +import { renderApp, type RenderedApp } from "./renderReviewPanel"; +import { cleanupView, setupWorkspaceView } from "./helpers"; +import { createTestEnvironment, cleanupTestEnvironment, type TestEnvironment } from "../ipc/setup"; +import { cleanupTempGitRepo, createTempGitRepo, generateBranchName } from "../ipc/helpers"; +import { detectDefaultTrunkBranch } from "@/node/git"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { UpdateStatus } from "@/common/orpc/types"; + +type MutableUpdateService = { + check: () => Promise; + download: () => Promise; + install: () => void; + currentStatus: UpdateStatus; + notifySubscribers: () => void; +}; + +function getUpdateService(env: TestEnvironment): MutableUpdateService { + return env.services.updateService as unknown as MutableUpdateService; +} + +function setDesktopApiEnabled() { + window.api = { + platform: process.platform, + versions: {}, + }; +} + +function clearDesktopApi() { + delete (window as Window & { api?: unknown }).api; +} + +function setUpdateStatus(updateService: MutableUpdateService, status: UpdateStatus) { + updateService.currentStatus = status; + updateService.notifySubscribers(); +} + +async function openAboutDialog(view: RenderedApp) { + const trigger = await waitFor(() => { + const triggerButton = view.container.querySelector( + 'button[aria-label="Open about dialog"]' + ) as HTMLButtonElement | null; + if (!triggerButton) { + throw new Error("About dialog trigger was not found in the title bar"); + } + return triggerButton; + }); + + fireEvent.click(trigger); + + const dialog = await waitFor(() => { + const dialogElement = view.container.ownerDocument.body.querySelector( + '[role="dialog"]' + ) as HTMLElement | null; + if (!dialogElement) { + throw new Error("About dialog did not open"); + } + return dialogElement; + }); + + return within(dialog); +} + +describe("About dialog (UI)", () => { + let env: TestEnvironment; + let repoPath: string; + let workspaceId: string; + let workspaceMetadata: FrontendWorkspaceMetadata; + let cleanupDom: (() => void) | null = null; + let view: RenderedApp | null = null; + + beforeAll(async () => { + env = await createTestEnvironment(); + repoPath = await createTempGitRepo(); + + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + const branchName = generateBranchName("about-dialog"); + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + if (!createResult.success) { + throw new Error(`Failed to create workspace: ${createResult.error}`); + } + + workspaceId = createResult.metadata.id; + workspaceMetadata = createResult.metadata; + }, 60_000); + + beforeEach(async () => { + clearDesktopApi(); + cleanupDom = installDom(); + view = renderApp({ apiClient: env.orpc, metadata: workspaceMetadata }); + await setupWorkspaceView(view, workspaceMetadata, workspaceId); + }, 60_000); + + afterEach(async () => { + clearDesktopApi(); + setUpdateStatus(getUpdateService(env), { type: "idle" }); + + if (view && cleanupDom) { + await cleanupView(view, cleanupDom); + } else { + cleanupDom?.(); + } + + view = null; + cleanupDom = null; + }); + + afterAll(async () => { + try { + const removeResult = await env.orpc.workspace.remove({ + workspaceId, + options: { force: true }, + }); + + if (!removeResult.success) { + console.warn("Failed to remove workspace during cleanup:", removeResult.error); + } + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(repoPath); + } + }, 60_000); + + test("clicking the title bar version opens the About dialog", async () => { + if (!view) { + throw new Error("App was not rendered"); + } + + const dialog = await openAboutDialog(view); + expect(dialog.getByRole("heading", { name: "About Mux" })).toBeTruthy(); + }); + + test("Check for Updates button calls api.update.check", async () => { + if (!view) { + throw new Error("App was not rendered"); + } + + const updateService = getUpdateService(env); + const originalCheck = updateService.check; + const checkSpy = jest.fn(async () => undefined); + updateService.check = checkSpy as typeof updateService.check; + + try { + setDesktopApiEnabled(); + + const dialog = await openAboutDialog(view); + fireEvent.click(dialog.getByRole("button", { name: "Check for Updates" })); + + await waitFor(() => { + expect(checkSpy).toHaveBeenCalledTimes(1); + }); + } finally { + updateService.check = originalCheck; + } + }); + + test("available update status shows Download button that calls api.update.download", async () => { + if (!view) { + throw new Error("App was not rendered"); + } + + const updateService = getUpdateService(env); + const originalDownload = updateService.download; + const downloadSpy = jest.fn(async () => undefined); + updateService.download = downloadSpy as typeof updateService.download; + + try { + setUpdateStatus(updateService, { + type: "available", + info: { version: "v9.9.9" }, + }); + setDesktopApiEnabled(); + + const dialog = await openAboutDialog(view); + const downloadButton = await waitFor(() => { + return dialog.getByRole("button", { name: "Download" }); + }); + + fireEvent.click(downloadButton); + + await waitFor(() => { + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + } finally { + updateService.download = originalDownload; + } + }); + + test("downloaded update status shows Install button that calls api.update.install", async () => { + if (!view) { + throw new Error("App was not rendered"); + } + + const updateService = getUpdateService(env); + const originalInstall = updateService.install; + const installSpy = jest.fn(() => undefined); + updateService.install = installSpy as typeof updateService.install; + + try { + setUpdateStatus(updateService, { + type: "downloaded", + info: { version: "v9.9.10" }, + }); + setDesktopApiEnabled(); + + const dialog = await openAboutDialog(view); + const installButton = await waitFor(() => { + return dialog.getByRole("button", { name: "Install & restart" }); + }); + + fireEvent.click(installButton); + + await waitFor(() => { + expect(installSpy).toHaveBeenCalledTimes(1); + }); + } finally { + updateService.install = originalInstall; + } + }); +}); From 7141728db5f37091ea58f73da6c5b458b96bcd64 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:06:57 +0000 Subject: [PATCH 2/2] fix: polish about dialog and debug updater behavior --- src/browser/components/About/AboutDialog.tsx | 52 ++++++++++++++------ src/desktop/updater.test.ts | 14 ++++++ src/desktop/updater.ts | 10 ++++ src/node/services/updateService.ts | 4 +- tests/ui/aboutDialog.integration.test.ts | 5 +- 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/browser/components/About/AboutDialog.tsx b/src/browser/components/About/AboutDialog.tsx index d0bcf2dbf9..b29f2fa100 100644 --- a/src/browser/components/About/AboutDialog.tsx +++ b/src/browser/components/About/AboutDialog.tsx @@ -97,31 +97,53 @@ export function AboutDialog() { }; }, [api, isDesktop, isOpen]); - const isChecking = updateStatus.type === "checking" || updateStatus.type === "downloading"; + useEffect(() => { + if (!isOpen || !api || !isDesktop) { + return; + } + + api.update.check(undefined).catch(console.error); + }, [api, isDesktop, isOpen]); + + const canUseUpdateApi = isDesktop && Boolean(api); + const isChecking = + canUseUpdateApi && (updateStatus.type === "checking" || updateStatus.type === "downloading"); const handleCheckForUpdates = () => { - api?.update.check(undefined).catch(console.error); + if (!api) { + return; + } + + api.update.check(undefined).catch(console.error); }; const handleDownload = () => { - api?.update.download(undefined).catch(console.error); + if (!api) { + return; + } + + api.update.download(undefined).catch(console.error); }; const handleInstall = () => { - api?.update.install(undefined).catch(console.error); + if (!api) { + return; + } + + api.update.install(undefined).catch(console.error); }; return ( !nextOpen && close()}> - - About Mux - -
- -
-
Mux
-
Parallel agent workflows
-
+ + About + +
+
@@ -142,6 +164,8 @@ export function AboutDialog() {
Desktop updates are available in the Electron app only.
+ ) : !canUseUpdateApi ? ( +
Connecting to desktop update service…
) : ( <>