mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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 | | --- | --- | --- | |  |  |  | ### 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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user