From 4d13bef74d68da89810a69073b94156d1da5df54 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 19 Aug 2026 09:11:07 +0700 Subject: [PATCH] feat(site): add Install Coder Desktop item to UserDropdown (#28244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Resolves [DEVEX-722](https://linear.app/codercom/issue/DEVEX-722/add-install-coder-desktop-button-to-userdropdown). Adds an **Install Coder Desktop** item to `UserDropdown`, sitting directly above the existing **Install CLI** option. Because `UserDropdownContent` is shared, it appears in both the navbar dropdown and the agents-page sidebar footer. ### Screenshots macOS/Windows shots are captured with `navigator.platform` overridden accordingly; the Linux shot is the real workspace platform, where the Coder Desktop item is hidden. Install Coder Desktop uses a monitor glyph, and Install CLI now uses a terminal glyph (it was a monitor on `main`). | `main` (before) | This PR · macOS/Windows | This PR · Linux / iPad OS | | --- | --- | --- | | ![UserDropdown on main](https://raw.githubusercontent.com/coder/coder/jakehwll/devex-722-assets/.github/assets/devex-722-userdropdown-main.png) | ![UserDropdown with Install Coder Desktop](https://raw.githubusercontent.com/coder/coder/jakehwll/devex-722-assets/.github/assets/devex-722-userdropdown-new.png) | ![UserDropdown on Linux without the Desktop item](https://raw.githubusercontent.com/coder/coder/jakehwll/devex-722-assets/.github/assets/devex-722-userdropdown-linux.png) | ### Behaviour - **Platform-gated visibility:** shown only on macOS and Windows (the platforms Coder Desktop ships for), hidden on Linux/other. The Linux client ([coder/coder-desktop-linux](https://github.com/coder/coder-desktop-linux)) is experimental and not advertised yet, so it stays hidden there. - **Links to the docs** (`https://coder.com/docs/user-guides/desktop`) rather than a single install method. The docs page is the canonical hub covering Homebrew, WinGet, manual release downloads, and source, and it stays current without frontend changes. The `coder-desktop-*` repo READMEs don't enumerate install methods; they defer to these same docs. - **No installed-detection:** consistent with the Install CLI item, we don't try to detect whether Coder Desktop is already present. ### Changes - `site/src/utils/platform.ts`: add `isWindows()` and `supportsCoderDesktop()`. - `UserDropdownContent.tsx`: render the platform-gated item (`MonitorIcon`) and switch Install CLI to `TerminalIcon`. - Unit tests for the platform helpers and Storybook coverage for the dropdown item (present + correct href on macOS/Windows, absent on Linux/iPadOS). Implementation plan & decision log **Ticket open questions and decisions** 1. *Shown to all users or only supported platforms?* Only supported platforms (macOS, Windows); hidden elsewhere. The Linux client is experimental and not advertised yet, so hiding it there is intentional. 2. *Where should it link?* The Coder Desktop docs page. Users install via many methods (brew, winget, releases, source); the docs are the single hub that lists them all and are maintained in-repo. The GitHub release pages / repo READMEs only cover downloads and defer back to the docs. 3. *Hide when already installed?* No. There is no reliable browser-side way to detect a native app install, and it mirrors how Install CLI behaves. **Implementation** - Extend `site/src/utils/platform.ts` with `isWindows()` and `supportsCoderDesktop()` (reusing the existing `isMac()`). - In `UserDropdownContent.tsx`, conditionally render an `Install Coder Desktop` `DropdownMenuItem` (external link, `MonitorIcon`, opens in a new tab) above `Install CLI` when `supportsCoderDesktop()` is true. - Tests: `platform.test.ts` (OS detection via `vi.stubGlobal`) and `UserDropdown.stories.tsx` (visibility + href per platform, mocking platform detection with `spyOn`). Left as a **draft** pending review, let me know when you'd like it opened. --- .../UserDropdown/UserDropdown.stories.tsx | 97 ++++++++++++++++++- .../UserDropdown/UserDropdownContent.tsx | 16 ++- site/src/utils/platform.test.ts | 44 +++++++++ site/src/utils/platform.ts | 22 +++++ 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 site/src/utils/platform.test.ts diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 8350304389..1879252b6d 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -1,5 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { + expect, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; import { meAISpendKey } from "#/api/queries/users"; import type { FeatureName, UserAISpendStatus } from "#/api/typesGenerated"; import { MockBuildInfo, MockUserOwner } from "#/testHelpers/entities"; @@ -52,6 +59,21 @@ const openDropdown = async (canvasElement: HTMLElement) => { ); }; +// Overrides platform detection so the Coder Desktop gating can be exercised in +// a story. Returns a cleanup that restores the spied getters. +const mockPlatform = (platform: string, maxTouchPoints = 0) => { + const platformSpy = spyOn(navigator, "platform", "get").mockReturnValue( + platform, + ); + const touchSpy = spyOn(navigator, "maxTouchPoints", "get").mockReturnValue( + maxTouchPoints, + ); + return () => { + platformSpy.mockRestore(); + touchSpy.mockRestore(); + }; +}; + const Example: Story = { parameters: { queries: [{ key: meAISpendKey, data: mockAISpend }], @@ -325,4 +347,77 @@ export const AISpendHiddenOnNegativeLimit: Story = { }, }; +export const InstallCoderDesktopMacOS: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("MacIntel"), + play: async ({ canvasElement, step }) => { + await step( + "links Install Coder Desktop to the docs alongside Install CLI", + async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.getByRole("menuitem", { name: "Install Coder Desktop" }), + ).toHaveAttribute("href", "https://coder.com/docs/user-guides/desktop"); + expect( + menu.getByRole("menuitem", { name: "Install CLI" }), + ).toBeInTheDocument(); + }, + ); + }, +}; + +export const InstallCoderDesktopWindows: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("Win32"), + play: async ({ canvasElement, step }) => { + await step("shows Install Coder Desktop on Windows", async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.getByRole("menuitem", { name: "Install Coder Desktop" }), + ).toBeInTheDocument(); + }); + }, +}; + +export const InstallCoderDesktopHiddenOnLinux: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + beforeEach: () => mockPlatform("Linux x86_64"), + play: async ({ canvasElement, step }) => { + await step( + "hides Install Coder Desktop but keeps Install CLI", + async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.queryByRole("menuitem", { name: "Install Coder Desktop" }), + ).not.toBeInTheDocument(); + expect( + menu.getByRole("menuitem", { name: "Install CLI" }), + ).toBeInTheDocument(); + }, + ); + }, +}; + +export const InstallCoderDesktopHiddenOniPadOS: Story = { + parameters: { + queries: [{ key: meAISpendKey, data: mockAISpend }], + }, + // iPadOS 13+ reports "MacIntel" but exposes a touchscreen. + beforeEach: () => mockPlatform("MacIntel", 5), + play: async ({ canvasElement, step }) => { + await step("hides Install Coder Desktop on iPadOS", async () => { + const menu = await openDropdown(canvasElement); + expect( + menu.queryByRole("menuitem", { name: "Install Coder Desktop" }), + ).not.toBeInTheDocument(); + }); + }, +}; + export { Example as UserDropdown }; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index ac26223268..12250f6cb6 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -2,8 +2,9 @@ import { CircleUserIcon, CopyIcon, LogOutIcon, - MonitorDownIcon, + MonitorIcon, SquareArrowOutUpRightIcon, + TerminalIcon, } from "lucide-react"; import type { FC, ReactNode } from "react"; import { Link } from "react-router"; @@ -19,8 +20,11 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { useClipboard } from "#/hooks/useClipboard"; +import { supportsCoderDesktop } from "#/utils/platform"; import { SupportIcon } from "../SupportIcon"; +const CODER_DESKTOP_DOCS_URL = "https://coder.com/docs/user-guides/desktop"; + interface UserDropdownContentProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; @@ -52,9 +56,17 @@ export const UserDropdownContent: FC = ({ {profileExtra} + {supportsCoderDesktop() && ( + + + + Install Coder Desktop + + + )} - + Install CLI diff --git a/site/src/utils/platform.test.ts b/site/src/utils/platform.test.ts new file mode 100644 index 0000000000..0052d8c5ad --- /dev/null +++ b/site/src/utils/platform.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMac, isWindows, supportsCoderDesktop } from "./platform"; + +const stubPlatform = (platform: string, maxTouchPoints = 0) => { + vi.stubGlobal("navigator", { + ...navigator, + platform, + maxTouchPoints, + }); +}; + +describe("platform", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("detects macOS", () => { + stubPlatform("MacIntel"); + expect(isMac()).toBe(true); + expect(isWindows()).toBe(false); + expect(supportsCoderDesktop()).toBe(true); + }); + + it("detects Windows", () => { + stubPlatform("Win32"); + expect(isWindows()).toBe(true); + expect(isMac()).toBe(false); + expect(supportsCoderDesktop()).toBe(true); + }); + + it("treats Linux and other platforms as unsupported", () => { + stubPlatform("Linux x86_64"); + expect(isMac()).toBe(false); + expect(isWindows()).toBe(false); + expect(supportsCoderDesktop()).toBe(false); + }); + + it("excludes iPadOS masquerading as macOS", () => { + // iPadOS 13+ reports "MacIntel" but exposes a touchscreen. + stubPlatform("MacIntel", 5); + expect(isMac()).toBe(true); + expect(supportsCoderDesktop()).toBe(false); + }); +}); diff --git a/site/src/utils/platform.ts b/site/src/utils/platform.ts index 5e2c07eab5..5bc8ad042d 100644 --- a/site/src/utils/platform.ts +++ b/site/src/utils/platform.ts @@ -1,10 +1,32 @@ /** * Returns true if the current platform is macOS. + * + * Note: iPadOS 13+ also reports `navigator.platform === "MacIntel"`. Callers + * that need to distinguish a real Mac from an iPad should additionally check + * `navigator.maxTouchPoints` (see `supportsCoderDesktop`). */ export function isMac(): boolean { return Boolean(navigator.platform.match("Mac")); } +/** + * Returns true if the current platform is Windows. + */ +export function isWindows(): boolean { + return navigator.platform.startsWith("Win"); +} + +/** + * Returns true if Coder Desktop is available for the current platform. + * Coder Desktop currently ships for macOS and Windows only, so we hide the + * install affordance everywhere else (e.g. Linux). iPadOS masquerades as macOS + * via `navigator.platform`, so it is excluded using the touchscreen tell. + */ +export function supportsCoderDesktop(): boolean { + const isIpadOS = isMac() && navigator.maxTouchPoints > 1; + return (isMac() || isWindows()) && !isIpadOS; +} + /** * Returns the platform-appropriate modifier key label: ⌘ on macOS, * Ctrl on everything else.