feat(site): add Install Coder Desktop item to UserDropdown (#28244)

> 🤖 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.
This commit is contained in:
Jake Howell
2026-08-19 09:11:07 +07:00
committed by GitHub
parent 60722bb653
commit 4d13bef74d
4 changed files with 176 additions and 3 deletions
@@ -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 };
@@ -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<UserDropdownContentProps> = ({
</DropdownMenuItem>
{profileExtra}
<DropdownMenuSeparator />
{supportsCoderDesktop() && (
<DropdownMenuItem asChild>
<a href={CODER_DESKTOP_DOCS_URL} target="_blank" rel="noreferrer">
<MonitorIcon />
<span>Install Coder Desktop</span>
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/install">
<MonitorDownIcon />
<TerminalIcon />
<span>Install CLI</span>
</Link>
</DropdownMenuItem>
+44
View File
@@ -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);
});
});
+22
View File
@@ -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.