feat(site/src): add ports submenu to WorkspacePill in agents chat (#24887)

Adds a **Ports (n) >** submenu item to the `WorkspacePill` dropdown
shown in the `/agents` chat UI when a workspace is attached, sitting
alongside VS Code, Terminal, and other app items.

The submenu shows a **Listening Ports** section with clickable port
links that open in a new tab, a **Shared Ports** section with
sharing-level icons when any ports are shared, and a **Manage sharing**
footer link to the workspace detail page. Port data is fetched on a 5s
polling interval while the dropdown is open and only when the agent is
connected. The trigger is disabled when the workspace is not running.

Also adds `DropdownMenuSub`, `DropdownMenuSubTrigger`, and
`DropdownMenuSubContent` to the shared `DropdownMenu` component for use
here and future consumers.

![ports submenu
demo](https://raw.githubusercontent.com/coder/coder/screenshots/ports-submenu/ports-submenu-demo.gif)

<details>
<summary>Implementation notes</summary>

- `host` for port-forward URL construction comes from
`useProxy().proxy.preferredWildcardHostname`, the same source used by
`PortForwardButton` on the workspace detail page.
- Port queries are gated on `isOpen && agent.status === "connected"` so
no requests fire when the dropdown is closed or the agent is
disconnected.
- Shared ports that overlap with listening ports are deduplicated: they
only appear in the Shared section.
- Port sharing controls (create/update/delete) are intentionally
excluded to keep the agents toolbar lightweight; the "Manage sharing"
link surfaces the workspace page for that.
- The port count badge on the trigger (`Ports (n)`) uses the raw
listening-port count, matching the existing `PortForwardButton` behavior
on the workspace detail page.
</details>

> Generated by [Coder Agent](https://coder.com)
This commit is contained in:
Ben Potter
2026-05-04 11:56:51 -05:00
committed by GitHub
parent c16034d127
commit f6eccbab23
5 changed files with 418 additions and 14 deletions
@@ -5,7 +5,7 @@
* This component was updated to match the styles from the Figma design:
* @see {@link https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=656-2354&t=CiGt5le3yJEwMH4M-0}
*/
import { CheckIcon } from "lucide-react";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "#/utils/cn";
import {
@@ -79,6 +79,37 @@ export const DropdownMenuRadioItem: React.FC<
);
};
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuSubTrigger: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
> = ({ className, inset, children, ...props }) => {
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(menuItemClass, inset && "pl-8", className)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-3.5" />
</DropdownMenuPrimitive.SubTrigger>
);
};
export const DropdownMenuSubContent: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubContent>
> = ({ className, ...props }) => {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
className={cn(menuContentClass, className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
};
export const DropdownMenuSeparator: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Separator>
> = ({ className, ...props }) => {
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities";
import { withProxyProvider } from "#/testHelpers/storybook";
import {
AgentChatInput,
type AgentContextUsage,
@@ -25,6 +26,7 @@ const defaultModelOptions = [
const meta: Meta<typeof AgentChatInput> = {
title: "pages/AgentsPage/AgentChatInput",
component: AgentChatInput,
decorators: [withProxyProvider()],
args: {
onSend: fn(),
onContentChange: fn(),
@@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { WorkspaceApp } from "#/api/typesGenerated";
import {
MockListeningPortsResponse,
MockSharedPortsResponse,
MockStoppedWorkspace,
MockWorkspace,
MockWorkspaceAgent,
@@ -62,7 +64,12 @@ const hiddenApp: WorkspaceApp = {
const agentWithApps = {
...MockWorkspaceAgent,
display_apps: ["vscode", "vscode_insiders", "web_terminal"] as const,
display_apps: [
"vscode",
"vscode_insiders",
"web_terminal",
"port_forwarding_helper",
] as const,
apps: [externalApp, cursorApp],
};
@@ -97,9 +104,20 @@ const agentWithHiddenApp = {
const meta: Meta<typeof WorkspacePill> = {
title: "pages/AgentsPage/WorkspacePill",
component: WorkspacePill,
// useAppLink calls useProxy(), so we need the proxy provider for
// stories that render AppMenuItem.
decorators: [withProxyProvider()],
// useAppLink and useProxy are called inside sub-components, so we need the
// proxy provider for all stories. A non-empty wildcard hostname is required
// so the Ports sub-trigger renders (it is hidden when port-forwarding is not
// configured).
decorators: [
withProxyProvider({
proxy: {
proxy: undefined,
preferredPathAppURL: "",
preferredWildcardHostname: "*.coder.com",
},
}),
],
parameters: {
layout: "centered",
queries: [{ key: ["me", "apiKey"], data: { key: "mock-api-key" } }],
@@ -277,6 +295,141 @@ export const WithStoppedWorkspace: Story = {
// View Workspace link should still be accessible.
expect(body.getByText("View Workspace")).toBeInTheDocument();
// Ports sub-trigger should be disabled when workspace is stopped.
const portsItem = body.getByText("Ports").closest("[role=menuitem]");
expect(portsItem).toHaveAttribute("aria-disabled", "true");
});
},
};
export const WithListeningPorts: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: MockWorkspaceAgent,
},
parameters: {
queries: [
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
{
key: ["portForward", MockWorkspaceAgent.id],
data: MockListeningPortsResponse,
},
{
key: ["sharedPorts", MockWorkspace.id],
data: { shares: [] },
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
const body = within(document.body);
await waitFor(() => {
// The ports sub-trigger should show the count.
expect(body.getByText(/Ports \(\d+\)/)).toBeInTheDocument();
});
// Hover over the ports item to open the submenu.
await userEvent.hover(body.getByText(/Ports \(\d+\)/));
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
expect(body.getByText("8080")).toBeInTheDocument();
expect(body.getByText("gogo")).toBeInTheDocument();
expect(body.getByText("30000")).toBeInTheDocument();
expect(body.getByText("webb")).toBeInTheDocument();
expect(body.getByText("Manage sharing")).toBeInTheDocument();
// Port items render as anchor links.
const port8080Anchor = body.getByText("8080").closest("a");
expect(port8080Anchor).toHaveAttribute("href");
});
},
};
export const WithSharedPorts: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: {
...MockWorkspaceAgent,
name: "a-workspace-agent",
},
},
parameters: {
queries: [
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
{
key: ["portForward", MockWorkspaceAgent.id],
data: MockListeningPortsResponse,
},
{
key: ["sharedPorts", MockWorkspace.id],
data: MockSharedPortsResponse,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
const body = within(document.body);
await waitFor(() => {
expect(body.getByText(/Ports/)).toBeInTheDocument();
});
await userEvent.hover(body.getByText(/Ports/));
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
expect(body.getByText("Shared Ports")).toBeInTheDocument();
// Shared ports from MockSharedPortsResponse for this agent.
expect(body.getByText("4000")).toBeInTheDocument();
expect(body.getByText("Manage sharing")).toBeInTheDocument();
// Port 8081 is both listening and shared; deduplication ensures it
// appears only in the Shared Ports section, not in Listening Ports.
expect(body.getAllByText("8081")).toHaveLength(1);
});
},
};
export const EmptyPorts: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: MockWorkspaceAgent,
},
parameters: {
queries: [
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
{
key: ["portForward", MockWorkspaceAgent.id],
data: { ports: [] },
},
{
key: ["sharedPorts", MockWorkspace.id],
data: { shares: [] },
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Ports (0)")).toBeInTheDocument();
});
await userEvent.hover(body.getByText("Ports (0)"));
await waitFor(() => {
expect(body.getByText("No open ports detected.")).toBeInTheDocument();
});
},
};
@@ -1,20 +1,29 @@
import {
BuildingIcon,
ChevronDownIcon,
CopyIcon,
ExternalLinkIcon,
LayoutGridIcon,
LockIcon,
LockOpenIcon,
MonitorIcon,
NetworkIcon,
RadioIcon,
SquareTerminalIcon,
} from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useMutation } from "react-query";
import { useMutation, useQuery } from "react-query";
import { Link } from "react-router";
import { toast } from "sonner";
import { API } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
import { workspacePortShares } from "#/api/queries/workspaceportsharing";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentListeningPort,
WorkspaceAgentPortShare,
WorkspaceApp,
} from "#/api/typesGenerated";
import {
@@ -22,6 +31,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
@@ -32,6 +44,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useProxy } from "#/contexts/ProxyContext";
import { useClipboard } from "#/hooks/useClipboard";
import {
getTerminalHref,
@@ -42,6 +55,10 @@ import {
} from "#/modules/apps/apps";
import { useAppLink } from "#/modules/apps/useAppLink";
import { cn } from "#/utils/cn";
import {
getWorkspaceListeningPortsProtocol,
portForwardURL,
} from "#/utils/portForward";
import { getWorkspaceStatus, StatusIcon } from "./StatusIcon";
interface WorkspacePillProps {
@@ -69,16 +86,24 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
const { mutate: generateKey, isPending: isGeneratingKey } = useMutation({
mutationFn: () => API.getApiKey(),
});
const { proxy } = useProxy();
const host = proxy.preferredWildcardHostname;
const builtinApps = new Set(agent.display_apps);
const hasVSCode = builtinApps.has("vscode");
const hasVSCodeInsiders = builtinApps.has("vscode_insiders");
const hasTerminal = builtinApps.has("web_terminal");
const portForwardingEnabled =
host !== "" && builtinApps.has("port_forwarding_helper");
const userApps = agent.apps.filter((app) => !app.hidden);
const hasItemsAboveSeparator =
hasVSCode || hasVSCodeInsiders || userApps.length > 0 || hasTerminal;
hasVSCode ||
hasVSCodeInsiders ||
userApps.length > 0 ||
hasTerminal ||
portForwardingEnabled;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -165,7 +190,17 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
isRunning={isRunning}
/>
)}
{portForwardingEnabled && (
<PortsSubMenuItem
workspace={workspace}
agent={agent}
host={host}
isOpen={open}
isRunning={isRunning}
/>
)}
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
<DropdownMenuItem asChild>
<Link to={route} target="_blank" rel="noreferrer">
@@ -178,6 +213,183 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
);
};
const PortsSubMenuItem: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
host: string;
isOpen: boolean;
isRunning: boolean;
}> = ({ workspace, agent, host, isOpen, isRunning }) => {
const route = `/@${workspace.owner_name}/${workspace.name}`;
const isConnected = agent.status === "connected";
const enabled = isOpen && isConnected;
const protocol = getWorkspaceListeningPortsProtocol(workspace.id);
const { data: listeningPorts } = useQuery({
queryKey: ["portForward", agent.id],
queryFn: () => API.getAgentListeningPorts(agent.id),
enabled,
refetchInterval: enabled ? 5_000 : false,
staleTime: 0,
select: (res) => res.ports,
});
const { data: sharedPorts } = useQuery({
...workspacePortShares(workspace.id),
enabled,
staleTime: 0,
select: (res) => res.shares.filter((s) => s.agent_name === agent.name),
});
// Listening ports that haven't been explicitly shared appear in their own
// section; shared ports bubble up to the "Shared" section.
const sharedPortNumbers = new Set((sharedPorts ?? []).map((s) => s.port));
const privateListeningPorts = (listeningPorts ?? []).filter(
(p) => !sharedPortNumbers.has(p.port),
);
const totalCount =
listeningPorts !== undefined ? listeningPorts.length : undefined;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!isRunning}>
<NetworkIcon className="size-3.5" />
{totalCount !== undefined ? `Ports (${totalCount})` : "Ports"}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5">
{/* Listening Ports header: only render when there are ports to list. */}
{privateListeningPorts.length > 0 && (
<div className="px-2 pb-1.5 pt-1">
<span className="text-xs font-semibold text-content-secondary">
Listening Ports
</span>
</div>
)}
{privateListeningPorts.map((port) => (
<ListeningPortItem
key={port.port}
port={port}
host={host}
agentName={agent.name}
workspaceName={workspace.name}
ownerName={workspace.owner_name}
protocol={protocol}
/>
))}
{listeningPorts !== undefined &&
sharedPorts !== undefined &&
privateListeningPorts.length === 0 &&
sharedPorts.length === 0 && (
<p className="px-2 py-2 text-center text-xs text-content-tertiary">
No open ports detected.
</p>
)}
{/* Shared Ports */}
{(sharedPorts ?? []).length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<div className="px-2 pb-1.5 pt-1">
<span className="text-xs font-semibold text-content-secondary">
Shared Ports
</span>
</div>
{(sharedPorts ?? []).map((share) => (
<SharedPortItem
key={share.port}
share={share}
host={host}
agentName={agent.name}
workspaceName={workspace.name}
ownerName={workspace.owner_name}
/>
))}
</>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem asChild>
<Link to={route} target="_blank" rel="noreferrer">
<ExternalLinkIcon className="size-3.5" />
Manage sharing
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
};
const ListeningPortItem: FC<{
port: WorkspaceAgentListeningPort;
host: string;
agentName: string;
workspaceName: string;
ownerName: string;
protocol: "http" | "https";
}> = ({ port, host, agentName, workspaceName, ownerName, protocol }) => {
const url = portForwardURL(
host,
port.port,
agentName,
workspaceName,
ownerName,
protocol,
);
return (
<DropdownMenuItem asChild>
<a href={url} target="_blank" rel="noreferrer">
<RadioIcon className="size-3.5 shrink-0" />
<span className="font-mono tabular-nums">{port.port}</span>
{port.process_name !== "" && (
<span className="truncate text-content-tertiary">
{port.process_name}
</span>
)}
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
</a>
</DropdownMenuItem>
);
};
const SharedPortItem: FC<{
share: WorkspaceAgentPortShare;
host: string;
agentName: string;
workspaceName: string;
ownerName: string;
}> = ({ share, host, agentName, workspaceName, ownerName }) => {
const url = portForwardURL(
host,
share.port,
agentName,
workspaceName,
ownerName,
share.protocol,
);
const ShareIcon =
share.share_level === "public"
? LockOpenIcon
: share.share_level === "organization"
? BuildingIcon
: LockIcon;
return (
<DropdownMenuItem asChild>
<a href={url} target="_blank" rel="noreferrer">
<ShareIcon className="size-3.5 shrink-0" />
<span className="font-mono tabular-nums">{share.port}</span>
<span className="truncate capitalize text-content-tertiary">
{share.share_level}
</span>
<ExternalLinkIcon className="ml-auto size-3.5 shrink-0 opacity-50" />
</a>
</DropdownMenuItem>
);
};
const VSCodeMenuItem: FC<{
variant: "vscode" | "vscode-insiders";
label: string;
+13 -7
View File
@@ -38,14 +38,20 @@ export const portForwardURL = (
const subdomain = `${port}${suffix}--${agentName}--${workspaceName}--${username}`;
const baseUrl = `${location.protocol}//${host.replace(/\*/g, subdomain)}`;
const url = new URL(baseUrl);
if (pathname) {
url.pathname = pathname;
try {
const url = new URL(baseUrl);
if (pathname) {
url.pathname = pathname;
}
if (search) {
url.search = search;
}
return url.toString();
} catch {
// When the proxy host is empty or invalid, return a do-nothing anchor
// so the link renders without navigating anywhere.
return "#";
}
if (search) {
url.search = search;
}
return url.toString();
};
/**