fix: inline ports panel in workspace pill on mobile (#25042)

closes CODAGT-326

<img width="335" height="658" alt="Screenshot 2026-06-02 at 18 29 39"
src="https://github.com/user-attachments/assets/ee103047-67ad-403c-b67b-eb2147726db3"
/>


On viewports below the `md` Tailwind breakpoint, the agents-chat
workspace pill becomes full width via the existing
`mobile-full-width-dropdown` CSS hook. The `Ports (X)` item used a Radix
`DropdownMenuSub` which opened a flyout sub-content to the right of the
parent menu, so on mobile the sub-content had nowhere to render and
clipped off the right edge.

Mirror the inline sub-panel pattern already used by the agent chat input
plus menu: lift a `view: "main" | "ports"` state into `WorkspacePill`,
and on mobile swap the same dropdown's contents to a `Back` + ports list
panel instead of opening a flyout. Desktop keeps the existing flyout
sub-menu unchanged.

Closes
[CODAGT-326](https://linear.app/codercom/issue/CODAGT-326/port-forward-menu-is-cut-off-on-mobile-viewports).

Visual coverage is added via two new Storybook stories at the `mobile1`
(375 px) viewport: `MobilePortsInlinePanel` (full interaction +
assertions) and `MobilePortsInlinePanelOpen` (visual / Chromatic capture
stop point).

<details>
<summary>Implementation plan</summary>

### Why

The dropdown content already gets full viewport width on `< 768 px` via
the `mobile-full-width-dropdown` CSS hook in `site/src/index.css`. A
Radix `DropdownMenuSub` opens to the right of its trigger, so on mobile
it has no room and clips. Forcing the sub-content to also be full-width
would overlap the parent and break keyboard / focus flow. The existing
convention for nested menus on mobile in this codebase is the inline
sub-panel (see `plusMenuView` in `AgentChatInput.tsx`).

### `WorkspacePill.tsx`

- Add `view: "main" | "ports"` state and reset to `"main"` on close.
- Extract `usePortsData(workspace, agent, enabled)` and a shared
`PortsList` so desktop sub-content and mobile inline panel share one
renderer.
- Replace `PortsSubMenuItem` with `PortsMenuItem`, which uses
`useIsBelowMdViewport()` to render either:
- **Mobile:** a regular `DropdownMenuItem` whose `onSelect` calls
`event.preventDefault()` (keeps the dropdown open) and switches to the
inline view.
- **Desktop:** the existing `DropdownMenuSub` flyout, behavior
unchanged.
- Add `MobilePortsPanel` rendered inside the parent
`DropdownMenuContent` when `view === "ports"`. Includes a `Back` item
that returns to the main view.
- Add a small reactive `useIsBelowMdViewport()` hook around the existing
`isBelowMdViewport` helper.

</details>

> Created on behalf of @jaayden by Coder Agents.
This commit is contained in:
Jaayden Halko
2026-06-02 14:22:15 +01:00
committed by GitHub
parent 93b067f5f2
commit eea427f288
5 changed files with 571 additions and 261 deletions
+12
View File
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from "react";
import { belowMdViewportMediaQuery, isBelowMdViewport } from "#/utils/mobile";
const subscribeBelowMdViewport = (onStoreChange: () => void) => {
const mediaQuery = window.matchMedia(belowMdViewportMediaQuery);
mediaQuery.addEventListener("change", onStoreChange);
return () => mediaQuery.removeEventListener("change", onStoreChange);
};
export const useIsBelowMdViewport = (): boolean => {
return useSyncExternalStore(subscribeBelowMdViewport, isBelowMdViewport);
};
@@ -478,3 +478,105 @@ export const EmptyPorts: Story = {
});
},
};
const mobilePortsStoryConfig = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: {
...MockWorkspaceAgent,
name: "a-workspace-agent",
},
},
parameters: {
viewport: { defaultViewport: "mobile1" },
chromatic: { viewports: [375] },
queries: [
{ key: ["me", "apiKey"], data: { key: "mock-api-key" } },
{
key: ["portForward", MockWorkspaceAgent.id],
data: MockListeningPortsResponse,
},
{
key: ["sharedPorts", MockWorkspace.id],
data: MockSharedPortsResponse,
},
],
},
} satisfies Partial<Story>;
const openMobilePortsPanel = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
const pill = await canvas.findByRole("button", {
name: /workspace menu/,
});
await userEvent.click(pill);
const body = within(document.body);
const portsItem = await body.findByText(/Ports \(\d+\)/);
await userEvent.click(portsItem);
return { body, pill };
};
export const MobilePortsInlinePanel: Story = {
...mobilePortsStoryConfig,
play: async ({ canvasElement }) => {
const { body, pill } = await openMobilePortsPanel(canvasElement);
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
expect(body.getByText("Shared Ports")).toBeInTheDocument();
expect(body.getByText("Manage sharing")).toBeInTheDocument();
expect(body.getByRole("menuitem", { name: /Back/ })).toHaveFocus();
expect(body.queryByText("View Workspace")).not.toBeInTheDocument();
});
const portsHeader = body.getByText("Listening Ports");
const dropdown: HTMLElement | null = portsHeader.closest(
"[data-radix-popper-content-wrapper]",
);
expect(dropdown).not.toBeNull();
if (dropdown === null) {
throw new Error("Expected dropdown wrapper to exist");
}
const rect = dropdown.getBoundingClientRect();
expect(rect.right).toBeLessThanOrEqual(innerWidth);
expect(rect.left).toBeGreaterThanOrEqual(0);
await userEvent.click(body.getByRole("menuitem", { name: /Back/ }));
await waitFor(() => {
expect(body.getByText("View Workspace")).toBeInTheDocument();
expect(body.getByRole("menuitem", { name: /Ports/ })).toHaveFocus();
expect(body.queryByText("Listening Ports")).not.toBeInTheDocument();
});
await userEvent.click(body.getByText(/Ports \(\d+\)/));
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
expect(body.getByRole("menuitem", { name: /Back/ })).toHaveFocus();
});
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(body.queryByText("Listening Ports")).not.toBeInTheDocument();
});
await userEvent.click(pill);
await waitFor(() => {
expect(body.getByText("View Workspace")).toBeInTheDocument();
expect(body.queryByText("Listening Ports")).not.toBeInTheDocument();
});
},
};
export const MobilePortsInlinePanelOpen: Story = {
...mobilePortsStoryConfig,
play: async ({ canvasElement }) => {
const { body } = await openMobilePortsPanel(canvasElement);
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
});
},
};
@@ -1,30 +1,21 @@
import {
BuildingIcon,
ChevronDownIcon,
CopyIcon,
ExternalLinkIcon,
LayoutGridIcon,
LockIcon,
LockOpenIcon,
MonitorIcon,
NetworkIcon,
RadioIcon,
SquareTerminalIcon,
UnlinkIcon,
} from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useMutation, useQuery } from "react-query";
import { useEffect, useState } from "react";
import { useMutation } 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 {
@@ -32,9 +23,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
@@ -47,6 +35,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { useProxy } from "#/contexts/ProxyContext";
import { useClipboard } from "#/hooks/useClipboard";
import { useIsBelowMdViewport } from "#/hooks/useIsBelowMdViewport";
import {
getTerminalHref,
getVSCodeHref,
@@ -56,11 +45,12 @@ 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";
import {
MobilePortsPanel,
PortsMenuItem,
usePortsData,
} from "./WorkspacePillPorts";
interface WorkspacePillProps {
workspace: Workspace;
@@ -108,8 +98,36 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
hasTerminal ||
portForwardingEnabled;
// Flyout sub-menus clip on mobile.
const [view, setView] = useState<"main" | "ports">("main");
const [focusPortsOnMain, setFocusPortsOnMain] = useState(false);
const isBelowMd = useIsBelowMdViewport();
const showPortsView = view === "ports" && isBelowMd;
const portsData = usePortsData(
workspace,
agent,
open && agent.status === "connected" && portForwardingEnabled,
);
useEffect(() => {
if (!isBelowMd && view === "ports") {
setView("main");
setFocusPortsOnMain(false);
}
}, [isBelowMd, view]);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setView("main");
setFocusPortsOnMain(false);
}
}}
>
<span className="inline-flex min-w-0 items-center overflow-hidden rounded-full bg-surface-secondary text-xs font-medium text-content-secondary md:min-w-[2.75rem]">
<Tooltip
open={tooltipOpen}
@@ -152,76 +170,100 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
align="start"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
>
{hasVSCode && (
<VSCodeMenuItem
variant="vscode"
label="VS Code"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{hasVSCodeInsiders && (
<VSCodeMenuItem
variant="vscode-insiders"
label="VS Code Insiders"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{userApps.map((app) => (
<AppMenuItem
key={app.id}
app={app}
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
))}
{hasTerminal && (
<TerminalMenuItem
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
)}
{portForwardingEnabled && (
<PortsSubMenuItem
{showPortsView ? (
<MobilePortsPanel
workspace={workspace}
agent={agent}
host={host}
isOpen={open}
isRunning={isRunning}
portsData={portsData}
onBack={() => {
setFocusPortsOnMain(true);
setView("main");
}}
/>
)}
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
<DropdownMenuItem asChild>
<Link to={route} target="_blank" rel="noreferrer">
<MonitorIcon className="size-3.5" />
View Workspace
</Link>
</DropdownMenuItem>
{onRemoveWorkspace && (
) : (
<>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRemoveWorkspace}
>
<UnlinkIcon className="size-3.5" />
Detach workspace
{hasVSCode && (
<VSCodeMenuItem
variant="vscode"
label="VS Code"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{hasVSCodeInsiders && (
<VSCodeMenuItem
variant="vscode-insiders"
label="VS Code Insiders"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{userApps.map((app) => (
<AppMenuItem
key={app.id}
app={app}
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
))}
{hasTerminal && (
<TerminalMenuItem
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
)}
{portForwardingEnabled && (
<PortsMenuItem
workspace={workspace}
agent={agent}
host={host}
portsData={portsData}
isRunning={isRunning}
isBelowMd={isBelowMd}
focusOnMount={focusPortsOnMain}
onFocusApplied={() => setFocusPortsOnMain(false)}
onSelectInline={() => {
setFocusPortsOnMain(false);
setView("ports");
}}
/>
)}
{hasItemsAboveSeparator && (
<DropdownMenuSeparator className="my-1" />
)}
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
<DropdownMenuItem asChild>
<Link to={route} target="_blank" rel="noreferrer">
<MonitorIcon className="size-3.5" />
View Workspace
</Link>
</DropdownMenuItem>
{onRemoveWorkspace && (
<>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRemoveWorkspace}
>
<UnlinkIcon className="size-3.5" />
Detach workspace
</DropdownMenuItem>
</>
)}
</>
)}
</DropdownMenuContent>
@@ -229,183 +271,6 @@ 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;
@@ -0,0 +1,329 @@
import {
ArrowLeftIcon,
BuildingIcon,
ChevronRightIcon,
ExternalLinkIcon,
LockIcon,
LockOpenIcon,
NetworkIcon,
RadioIcon,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
import { API } from "#/api/api";
import { workspacePortShares } from "#/api/queries/workspaceportsharing";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentListeningPort,
WorkspaceAgentPortShare,
} from "#/api/typesGenerated";
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import {
getWorkspaceListeningPortsProtocol,
portForwardURL,
} from "#/utils/portForward";
interface PortsData {
listeningPorts: readonly WorkspaceAgentListeningPort[] | undefined;
sharedPorts: readonly WorkspaceAgentPortShare[] | undefined;
privateListeningPorts: readonly WorkspaceAgentListeningPort[];
totalCount: number | undefined;
protocol: "http" | "https";
}
export const usePortsData = (
workspace: Workspace,
agent: WorkspaceAgent,
enabled: boolean,
): PortsData => {
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 {
listeningPorts,
sharedPorts,
privateListeningPorts,
totalCount,
protocol,
};
};
export const PortsMenuItem: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
host: string;
portsData: PortsData;
isRunning: boolean;
isBelowMd: boolean;
focusOnMount: boolean;
onFocusApplied: () => void;
onSelectInline: () => void;
}> = ({
workspace,
agent,
host,
portsData,
isRunning,
isBelowMd,
focusOnMount,
onFocusApplied,
onSelectInline,
}) => {
const itemRef = useRef<HTMLDivElement>(null);
const label =
portsData.totalCount !== undefined
? `Ports (${portsData.totalCount})`
: "Ports";
useEffect(() => {
if (!focusOnMount || !isBelowMd) {
return;
}
itemRef.current?.focus();
onFocusApplied();
}, [focusOnMount, isBelowMd, onFocusApplied]);
if (isBelowMd) {
return (
<DropdownMenuItem
ref={itemRef}
disabled={!isRunning}
onSelect={(event) => {
event.preventDefault();
onSelectInline();
}}
>
<NetworkIcon className="size-3.5" />
{label}
<ChevronRightIcon className="ml-auto size-3.5" />
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!isRunning}>
<NetworkIcon className="size-3.5" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5">
<PortsList
host={host}
agent={agent}
workspace={workspace}
data={portsData}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
};
export const MobilePortsPanel: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
host: string;
portsData: PortsData;
onBack: () => void;
}> = ({ workspace, agent, host, portsData, onBack }) => {
const backRef = useRef<HTMLDivElement>(null);
useEffect(() => {
backRef.current?.focus();
}, []);
return (
<>
<DropdownMenuItem
ref={backRef}
onSelect={(event) => {
event.preventDefault();
onBack();
}}
>
<ArrowLeftIcon className="size-3.5" />
Back
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<PortsList
host={host}
agent={agent}
workspace={workspace}
data={portsData}
/>
</>
);
};
const PortsList: FC<{
host: string;
agent: WorkspaceAgent;
workspace: Workspace;
data: PortsData;
}> = ({ host, agent, workspace, data }) => {
const route = `/@${workspace.owner_name}/${workspace.name}`;
const { listeningPorts, sharedPorts, privateListeningPorts, protocol } = data;
return (
<>
{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>
)}
{(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>
</>
);
};
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>
);
};
+3 -1
View File
@@ -8,6 +8,8 @@ export const isMobileViewport = (): boolean => {
return window.matchMedia("(max-width: 639px)").matches;
};
export const belowMdViewportMediaQuery = "(max-width: 767px)";
/**
* Returns `true` when the viewport width is below the `md` Tailwind
* breakpoint (< 768 px). Use this for layout branching that needs to
@@ -17,5 +19,5 @@ export const isMobileViewport = (): boolean => {
* mobile branch instead of the desktop flyout branch.
*/
export const isBelowMdViewport = (): boolean => {
return window.matchMedia("(max-width: 767px)").matches;
return window.matchMedia(belowMdViewportMediaQuery).matches;
};