diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index a6f6431e71..c3828b131e 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -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 & { + inset?: boolean; + } +> = ({ className, inset, children, ...props }) => { + return ( + + {children} + + + ); +}; + +export const DropdownMenuSubContent: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ( + + + + ); +}; + export const DropdownMenuSeparator: React.FC< React.ComponentPropsWithRef > = ({ className, ...props }) => { diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 80b58f30c9..21b818523b 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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 = { title: "pages/AgentsPage/AgentChatInput", component: AgentChatInput, + decorators: [withProxyProvider()], args: { onSend: fn(), onContentChange: fn(), diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx index 2c65beb42d..ec8cb4f831 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx @@ -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 = { 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(); }); }, }; diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index 2d71327e0f..c25d62f584 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -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 = ({ 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 ( @@ -165,7 +190,17 @@ export const WorkspacePill: FC = ({ isRunning={isRunning} /> )} + {portForwardingEnabled && ( + + )} {hasItemsAboveSeparator && } + {sshCommand && } @@ -178,6 +213,183 @@ export const WorkspacePill: FC = ({ ); }; +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 ( + + + + {totalCount !== undefined ? `Ports (${totalCount})` : "Ports"} + + + {/* Listening Ports header: only render when there are ports to list. */} + {privateListeningPorts.length > 0 && ( +
+ + Listening Ports + +
+ )} + + {privateListeningPorts.map((port) => ( + + ))} + + {listeningPorts !== undefined && + sharedPorts !== undefined && + privateListeningPorts.length === 0 && + sharedPorts.length === 0 && ( +

+ No open ports detected. +

+ )} + + {/* Shared Ports */} + {(sharedPorts ?? []).length > 0 && ( + <> + +
+ + Shared Ports + +
+ {(sharedPorts ?? []).map((share) => ( + + ))} + + )} + + + + + + Manage sharing + + +
+
+ ); +}; + +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 ( + + + + {port.port} + {port.process_name !== "" && ( + + {port.process_name} + + )} + + + + ); +}; + +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 ( + + + + {share.port} + + {share.share_level} + + + + + ); +}; + const VSCodeMenuItem: FC<{ variant: "vscode" | "vscode-insiders"; label: string; diff --git a/site/src/utils/portForward.ts b/site/src/utils/portForward.ts index b8b8845368..ebfcd6f860 100644 --- a/site/src/utils/portForward.ts +++ b/site/src/utils/portForward.ts @@ -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(); }; /**