feat: add right panel tabs for workspace apps and ports (#26208)

Adds workspace app and forwarded port tabs to the AgentsPage right panel, so app previews and port views can sit alongside terminals while working in the chat view.

- The new add-tab dropdown lists the agent's apps and a ports submenu: embeddable apps open as iframe tabs, command apps open as renamed terminal tabs running their command, and ports open in a new port preview panel served through the wildcard access URL.
- Tabs persist per chat and are validated against the current workspace state: tabs whose app disappears, stops being embeddable, or whose agent no longer exposes the port forwarding helper are hidden rather than deleted, and reappear if the workspace exposes them again.
- A shared `usePortsData` hook keeps the add-tab control, workspace pill, and existing `PortForwardButton` on the same port queries and refresh cadence.
- ~~App and port tabs are limited to `devel` builds for now; terminal tabs remain generally available. This was done in favour of adding a backend experiment for a frontend-only feature.~~ TODO: I'm just gonna switch to a backend experiment.

Closes CODAGT-346
This commit is contained in:
Ethan
2026-06-17 13:59:21 +10:00
committed by GitHub
parent 744c00f45c
commit 5ee1946b67
14 changed files with 1347 additions and 176 deletions
+8
View File
@@ -17,6 +17,7 @@ import type {
WorkspaceAgent,
WorkspaceAgentDevcontainer,
WorkspaceAgentListContainersResponse,
WorkspaceAgentListeningPortsResponse,
WorkspaceAgentLog,
WorkspaceBuild,
WorkspaceBuildParameter,
@@ -497,6 +498,13 @@ export const agentLogs = (agentId: string) => {
} satisfies UseQueryOptions<WorkspaceAgentLog[]>;
};
export const agentListeningPorts = (agentId: string) => {
return {
queryKey: ["portForward", agentId],
queryFn: () => API.getAgentListeningPorts(agentId),
} satisfies UseQueryOptions<WorkspaceAgentListeningPortsResponse>;
};
// workspace usage options
interface WorkspaceUsageOptions {
usageApp: UsageAppName;
+9 -9
View File
@@ -80,6 +80,7 @@ import { AgentSSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
import { useAgentContainers } from "./useAgentContainers";
import { useAgentLogs } from "./useAgentLogs";
import { canShowPortForwarding } from "./usePortsData";
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
import { WildcardHostnameWarning } from "./WildcardHostnameWarning";
@@ -451,15 +452,14 @@ export const AgentRow: FC<AgentRowProps> = ({
workspaceOwnerUsername={workspace.owner_name}
/>
)}
{proxy.preferredWildcardHostname !== "" &&
agent.display_apps.includes("port_forwarding_helper") && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
workspace={workspace}
agent={agent}
template={template}
/>
)}
{canShowPortForwarding(agent, proxy.preferredWildcardHostname) && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
workspace={workspace}
agent={agent}
template={template}
/>
)}
</div>
</header>
@@ -16,13 +16,11 @@ import {
XIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import { useMutation, useQuery } from "react-query";
import { useMutation } from "react-query";
import * as Yup from "yup";
import { API } from "#/api/api";
import {
deleteWorkspacePortShare,
upsertWorkspacePortShare,
workspacePortShares,
} from "#/api/queries/workspaceportsharing";
import {
type Template,
@@ -53,6 +51,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { usePortsData } from "#/modules/resources/usePortsData";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils";
import {
@@ -76,19 +75,11 @@ export const PortForwardButton: FC<PortForwardButtonProps> = ({
}) => {
const { entitlements } = useDashboard();
const { data: listeningPorts } = useQuery({
queryKey: ["portForward", agent.id],
queryFn: () => API.getAgentListeningPorts(agent.id),
enabled: agent.status === "connected",
refetchInterval: 5_000,
select: (res) => res.ports,
});
const { data: sharedPorts, refetch: refetchSharedPorts } = useQuery({
...workspacePortShares(workspace.id),
enabled: agent.status === "connected",
select: (res) => res.shares,
});
const { listeningPorts, sharedPorts, refetchSharedPorts } = usePortsData(
workspace,
agent,
agent.status === "connected",
);
return (
<Popover>
@@ -193,13 +184,10 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
});
const getFieldHelpers = getFormHelpers(form, submitError);
// filter out shared ports that are not from this agent
const filteredSharedPorts = sharedPorts.filter(
(port) => port.agent_name === agent.name,
);
// we don't want to show listening ports if it's a shared port
// usePortsData already filters shared ports down to this agent, so only
// hide listening ports that are also shared.
const filteredListeningPorts = listeningPorts.filter((port) =>
filteredSharedPorts.every((sharedPort) => sharedPort.port !== port.port),
sharedPorts.every((sharedPort) => sharedPort.port !== port.port),
);
// only disable the form if shared port controls are entitled and the template doesn't allow sharing ports
const canSharePorts = !(
@@ -423,7 +411,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
</HelpPopoverText>
{canSharePorts && (
<div>
{filteredSharedPorts?.map((share) => {
{sharedPorts.map((share) => {
const url = portForwardURL(
host,
share.port,
@@ -0,0 +1,79 @@
import { useQuery } from "react-query";
import { workspacePortShares } from "#/api/queries/workspaceportsharing";
import { agentListeningPorts } from "#/api/queries/workspaces";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentListeningPort,
WorkspaceAgentPortShare,
} from "#/api/typesGenerated";
import { getWorkspaceListeningPortsProtocol } from "#/utils/portForward";
/**
* Whether port-forwarding UI (ports menus, port preview tabs) should be shown
* for the agent: requires a configured wildcard access URL and the agent
* opting into the port_forwarding_helper display app.
*/
export const canShowPortForwarding = (
agent: WorkspaceAgent,
host: string,
): boolean => {
return (
host.trim() !== "" && agent.display_apps.includes("port_forwarding_helper")
);
};
export interface PortsData {
listeningPorts: readonly WorkspaceAgentListeningPort[] | undefined;
sharedPorts: readonly WorkspaceAgentPortShare[] | undefined;
privateListeningPorts: readonly WorkspaceAgentListeningPort[];
totalCount: number | undefined;
protocol: "http" | "https";
refetchSharedPorts: () => void;
}
/**
* Used by both the workspace port-forward button and the AgentsPage right-panel
* ports menu so they stay on the same query keys and refresh cadence.
*/
export const usePortsData = (
workspace: Workspace,
agent: WorkspaceAgent,
enabled: boolean,
): PortsData => {
const protocol = getWorkspaceListeningPortsProtocol(workspace.id);
const { data: listeningPorts } = useQuery({
...agentListeningPorts(agent.id),
enabled,
refetchInterval: enabled ? 5_000 : false,
staleTime: 0,
select: (res) => res.ports,
});
const { data: sharedPorts, refetch: refetchSharedPorts } = 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,
refetchSharedPorts,
};
};
+235 -58
View File
@@ -1,4 +1,4 @@
import { ArchiveIcon, PlusIcon, TriangleAlertIcon } from "lucide-react";
import { ArchiveIcon, TriangleAlertIcon } from "lucide-react";
import {
type FC,
@@ -18,9 +18,15 @@ import type {
ChatDiffStatus,
ChatMessagePart,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { useProxy } from "#/contexts/ProxyContext";
import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps";
import { WorkspaceAppFrame } from "#/modules/apps/WorkspaceAppFrame";
import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { getPrereleaseFlag } from "#/utils/buildInfo";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import { findWorkspaceAgent } from "#/utils/workspace";
import {
AgentChatInput,
type ChatMessageInputRef,
@@ -42,7 +48,9 @@ import { ChatTopBar } from "./components/ChatTopBar";
import { GitPanel } from "./components/GitPanel/GitPanel";
import { DebugPanel } from "./components/RightPanel/DebugPanel/DebugPanel";
import { DesktopPanel } from "./components/RightPanel/DesktopPanel";
import { PortPreviewPanel } from "./components/RightPanel/PortPreviewPanel";
import { RightPanel } from "./components/RightPanel/RightPanel";
import { RightPanelAddTabControl } from "./components/RightPanel/RightPanelAddTabControl";
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
@@ -53,7 +61,11 @@ import {
savePersistedDefaultTerminalHidden,
savePersistedRightPanelTabs,
} from "./utils/rightPanelTabStorage";
import type { UserRightPanelTab } from "./utils/rightPanelTabs";
import {
type PortSelection,
type UserRightPanelTab,
validateUserRightPanelTabs,
} from "./utils/rightPanelTabs";
import {
getPersistedSidebarTabId,
savePersistedSidebarTabId,
@@ -67,8 +79,6 @@ type ChatOwnerInfo = {
username?: string;
};
// Re-use the inner presentational components directly. They are
interface EditingState {
chatInputRef: RefObject<ChatMessageInputRef | null>;
editorInitialValue: string;
@@ -209,39 +219,89 @@ interface AgentChatPageViewProps {
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
}
interface UserTerminalTabContentProps {
const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => (
<div className="flex h-full min-h-0 items-center justify-center px-6 text-center text-xs text-content-secondary">
{message}
</div>
);
interface UserTabContentProps {
tab: UserRightPanelTab;
chatId: string;
workspace: TypesGen.Workspace;
workspaceAgent: TypesGen.WorkspaceAgent;
activeTabId: string | null;
pendingTabId: string | null;
isPanelVisible: boolean;
onReady: (tabId: string) => void;
workspace: TypesGen.Workspace | undefined;
workspaceAgent: TypesGen.WorkspaceAgent | undefined;
wildcardHostname: string;
sidebarVisible: boolean;
isActive: boolean;
isPending: boolean;
onTerminalReady: (tabId: string) => void;
}
const UserTerminalTabContent: FC<UserTerminalTabContentProps> = ({
const UserTabContent: FC<UserTabContentProps> = ({
tab,
chatId,
workspace,
workspaceAgent,
activeTabId,
pendingTabId,
isPanelVisible,
onReady,
wildcardHostname,
sidebarVisible,
isActive,
isPending,
onTerminalReady,
}) => {
const isActive = activeTabId === tab.id;
return (
<TerminalPanel
chatId={chatId}
reconnectionToken={tab.reconnectionToken}
isHot={isPanelVisible && (isActive || pendingTabId === tab.id)}
autoFocus={isPanelVisible && isActive}
onReady={() => onReady(tab.id)}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
);
switch (tab.kind) {
case "terminal":
return workspace && workspaceAgent ? (
<TerminalPanel
chatId={chatId}
reconnectionToken={tab.reconnectionToken}
initialCommand={tab.initialCommand}
isHot={sidebarVisible && (isActive || isPending)}
autoFocus={sidebarVisible && isActive}
onReady={() => onTerminalReady(tab.id)}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
) : (
<UnavailableTabMessage message="Terminal will be available once the workspace agent is ready." />
);
case "workspace_app": {
if (!workspace) {
return null;
}
const app = findWorkspaceAppWithAgent(workspace, tab.agentId, tab.appId);
if (!app || !isWorkspaceAppEmbeddable(app)) {
return (
<UnavailableTabMessage message="This workspace app is no longer available as a right-panel tab." />
);
}
return (
<WorkspaceAppFrame workspace={workspace} app={app} active={isActive} />
);
}
case "port": {
if (!workspace) {
return null;
}
const agent = findWorkspaceAgent(workspace, tab.agentId);
if (!agent) {
return (
<UnavailableTabMessage message="This port preview tab is no longer available." />
);
}
return (
<PortPreviewPanel
workspace={workspace}
agent={agent}
host={wildcardHostname}
tab={tab}
/>
);
}
default: {
const _exhaustive: never = tab;
return _exhaustive;
}
}
};
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
@@ -316,6 +376,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
lastInjectedContext,
}) => {
const queryClient = useQueryClient();
const { proxy } = useProxy();
const wildcardHostname = proxy.preferredWildcardHostname;
const { buildInfo } = useDashboard();
// Workspace app and port preview tabs are experimental and limited to
// devel builds for now. Terminal tabs are generally available.
const userAppTabsEnabled = getPrereleaseFlag(buildInfo) === "devel";
const canOpenChatSharing = canShareChat && organizationId !== undefined;
@@ -388,8 +454,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const shouldShowSidebar = showSidebarPanel;
// Compute local diff stats from git watcher unified diffs.
// Prefer the git repository root over the agent's expanded directory
// for VS Code folder resolution (important for monorepos).
const preferredFolder = (() => {
@@ -424,8 +488,17 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const availableDesktopChatId =
workspace && workspaceAgent ? desktopChatId : undefined;
const visibleUserTabs = workspace && workspaceAgent ? userRightPanelTabs : [];
// When app and port tabs are gated off, persisted tabs of those kinds
// are hidden rather than deleted; the save effect persists the raw tab
// state, so they reappear if the gate lifts.
const validatedUserRightPanelTabs = validateUserRightPanelTabs(
userRightPanelTabs,
{ workspace, workspaceAgent, wildcardHostname },
).filter((tab) => userAppTabsEnabled || tab.kind === "terminal");
const hasBuiltInTerminal = Boolean(
workspace && workspaceAgent && !defaultTerminalHidden,
);
// Single source of truth for available tabs and their order. The list
// of tab IDs used by `getEffectiveTabId` is derived from this so a
// new tab can never be added to one without the other going out of
@@ -434,17 +507,31 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
{ id: "git", label: "Git" },
...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []),
...(availableDesktopChatId ? [{ id: "desktop", label: "Desktop" }] : []),
...(workspace && workspaceAgent && !defaultTerminalHidden
? [{ id: "terminal", label: "Terminal" }]
: []),
...(hasBuiltInTerminal ? [{ id: "terminal", label: "Terminal" }] : []),
];
// Dense terminal numbering: position among unlabeled terminal tabs,
// after the built-in Terminal when visible. Labeled terminals (command
// apps) display their own label, so they don't consume a number.
// Closing a terminal renumbers the ones after it.
const terminalNumbers = new Map(
validatedUserRightPanelTabs
.filter((tab) => tab.kind === "terminal" && tab.label === undefined)
.map(
(tab, index) =>
[tab.id, (hasBuiltInTerminal ? 1 : 0) + index + 1] as const,
),
);
const sidebarTabConfigs = [
...builtInSidebarTabConfigs,
...visibleUserTabs.map((tab, index) => {
const terminalNumber = index + (defaultTerminalHidden ? 1 : 2);
// Only unlabeled terminal tabs fall through to the numbered label;
// every other tab kind has a required label.
...validatedUserRightPanelTabs.map((tab) => {
const terminalNumber = terminalNumbers.get(tab.id);
return {
id: tab.id,
label: terminalNumber === 1 ? "Terminal" : `Terminal ${terminalNumber}`,
label:
tab.label ??
(terminalNumber === 1 ? "Terminal" : `Terminal ${terminalNumber}`),
};
}),
];
@@ -455,6 +542,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
availableDesktopChatId,
);
const activateRightPanelTab = (tabId: string) => {
onSetShowSidebarPanel(true);
setPendingTabId(null);
setSidebarTabId(tabId);
};
// Ignore late readiness from a tab the user already navigated past.
const handleTerminalTabReady = (tabId: string) => {
if (pendingTabId !== tabId) {
@@ -474,6 +567,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
setPendingTabId(tabId);
};
const createUserRightPanelTabId = (
kind: UserRightPanelTab["kind"],
): string => {
return `${kind}-${uuidv4()}`;
};
const handleAddTerminalTab = () => {
if (!workspace || !workspaceAgent) {
return;
@@ -484,7 +583,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
startPendingTab("terminal");
return;
}
const tabId = `terminal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const tabId = createUserRightPanelTabId("terminal");
setUserRightPanelTabsState((currentTabs) => [
...currentTabs,
{
@@ -496,6 +595,81 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
startPendingTab(tabId);
};
const handleOpenWorkspaceAppTab = (app: TypesGen.WorkspaceApp) => {
if (!workspaceAgent) {
return;
}
const existingTab = validatedUserRightPanelTabs.find(
(tab) =>
tab.kind === "workspace_app" &&
tab.agentId === workspaceAgent.id &&
tab.appId === app.id,
);
if (existingTab) {
activateRightPanelTab(existingTab.id);
return;
}
const tab: UserRightPanelTab = {
id: createUserRightPanelTabId("workspace_app"),
kind: "workspace_app",
label: app.display_name ?? app.slug,
agentId: workspaceAgent.id,
appId: app.id,
};
setUserRightPanelTabsState((currentTabs) => [...currentTabs, tab]);
activateRightPanelTab(tab.id);
};
const handleOpenCommandAppTab = (app: TypesGen.WorkspaceApp) => {
if (!workspace || !workspaceAgent || !app.command) {
return;
}
const existingTab = validatedUserRightPanelTabs.find(
(tab) => tab.kind === "terminal" && tab.sourceAppId === app.id,
);
if (existingTab) {
activateRightPanelTab(existingTab.id);
return;
}
const tab: UserRightPanelTab = {
id: createUserRightPanelTabId("terminal"),
kind: "terminal",
label: app.display_name ?? app.slug,
reconnectionToken: uuidv4(),
initialCommand: app.command,
sourceAppId: app.id,
};
setUserRightPanelTabsState((currentTabs) => [...currentTabs, tab]);
startPendingTab(tab.id);
};
const handleOpenPortTab = (selection: PortSelection) => {
if (!workspaceAgent) {
return;
}
const existingTab = validatedUserRightPanelTabs.find(
(tab) =>
tab.kind === "port" &&
tab.agentId === workspaceAgent.id &&
tab.port === selection.port &&
tab.protocol === selection.protocol,
);
if (existingTab) {
activateRightPanelTab(existingTab.id);
return;
}
const tab: UserRightPanelTab = {
id: createUserRightPanelTabId("port"),
kind: "port",
label: selection.label,
agentId: workspaceAgent.id,
port: selection.port,
protocol: selection.protocol,
};
setUserRightPanelTabsState((currentTabs) => [...currentTabs, tab]);
activateRightPanelTab(tab.id);
};
const renderTabContent = (tabId: string): ReactNode => {
switch (tabId) {
case "git":
@@ -549,17 +723,20 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
/>
);
default: {
const userTab = visibleUserTabs.find((tab) => tab.id === tabId);
return userTab && workspace && workspaceAgent ? (
<UserTerminalTabContent
const userTab = validatedUserRightPanelTabs.find(
(tab) => tab.id === tabId,
);
return userTab ? (
<UserTabContent
tab={userTab}
chatId={agentId}
workspace={workspace}
workspaceAgent={workspaceAgent}
activeTabId={effectiveSidebarTabId}
pendingTabId={pendingTabId}
isPanelVisible={shouldShowSidebar}
onReady={handleTerminalTabReady}
wildcardHostname={wildcardHostname}
sidebarVisible={shouldShowSidebar}
isActive={effectiveSidebarTabId === userTab.id}
isPending={pendingTabId === userTab.id}
onTerminalReady={handleTerminalTabReady}
/>
) : null;
}
@@ -594,7 +771,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
const sidebarTabs = sidebarTabConfigs.map((tab) => {
const isCloseable =
tab.id === "terminal" ||
visibleUserTabs.some((userTab) => userTab.id === tab.id);
validatedUserRightPanelTabs.some((userTab) => userTab.id === tab.id);
return {
id: tab.id,
label: tab.label,
@@ -808,17 +985,17 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onActiveTabChange={handleActiveTabChange}
tabs={sidebarTabs}
addTabControl={
<Button
variant="outline"
size="icon"
onClick={handleAddTerminalTab}
disabled={!workspace || !workspaceAgent}
aria-label="New terminal tab"
title="New terminal tab"
className="size-6 bg-surface-primary p-0 text-content-secondary hover:text-content-primary"
>
<PlusIcon className="size-3.5" />
</Button>
<RightPanelAddTabControl
appExperimentEnabled={userAppTabsEnabled}
workspace={workspace}
agent={workspaceAgent}
host={wildcardHostname}
isRunning={workspace?.latest_build.status === "running"}
onNewTerminal={handleAddTerminalTab}
onOpenWorkspaceApp={handleOpenWorkspaceAppTab}
onOpenCommandApp={handleOpenCommandAppTab}
onOpenPort={handleOpenPortTab}
/>
}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities";
import type { UserRightPanelTab } from "../../utils/rightPanelTabs";
import { PortPreviewPanel } from "./PortPreviewPanel";
const previewTab: Extract<UserRightPanelTab, { kind: "port" }> = {
id: "port-3000",
kind: "port",
label: "Preview :3000",
agentId: MockWorkspaceAgent.id,
port: 3000,
protocol: "http",
};
const meta = {
title: "pages/AgentsPage/components/RightPanel/PortPreviewPanel",
component: PortPreviewPanel,
args: {
workspace: MockWorkspace,
agent: MockWorkspaceAgent,
host: "*.apps.example.com",
tab: previewTab,
},
parameters: {
layout: "centered",
chromatic: { disableSnapshot: true },
},
decorators: [
(Story) => (
<div style={{ width: 480, height: 420 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof PortPreviewPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByTitle("Preview :3000")).toBeInTheDocument();
await expect(canvas.getByLabelText("Open port in new tab")).toHaveAttribute(
"href",
expect.stringContaining("3000--"),
);
},
};
export const MissingWildcardHost: Story = {
args: {
host: "",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Port previews require a wildcard access URL."),
).toBeInTheDocument();
await expect(canvas.getByLabelText("Open port in new tab")).toBeDisabled();
},
};
export const AgentDisconnected: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "disconnected",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText(
"Port preview will be available once the workspace agent reconnects.",
),
).toBeInTheDocument();
await expect(canvas.getByLabelText("Open port in new tab")).toBeDisabled();
},
};
export const InvalidWildcardHost: Story = {
args: {
// Chromium percent-encodes spaces in hosts instead of rejecting them,
// so use a forbidden host code point that actually fails URL parsing.
host: "bad^host",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText(
"The wildcard access URL produced an invalid preview URL. Check the deployment's wildcard access URL configuration.",
),
).toBeInTheDocument();
await expect(canvas.getByLabelText("Open port in new tab")).toBeDisabled();
},
};
@@ -0,0 +1,85 @@
import { ExternalLinkIcon, NetworkIcon } from "lucide-react";
import type { FC } from "react";
import type { Workspace, WorkspaceAgent } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { WorkspaceIframe } from "#/modules/apps/WorkspaceAppFrame";
import { portForwardURL } from "#/utils/portForward";
import type { UserRightPanelTab } from "../../utils/rightPanelTabs";
export const PortPreviewPanel: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
host: string;
tab: Extract<UserRightPanelTab, { kind: "port" }>;
}> = ({ workspace, agent, host, tab }) => {
const url = portForwardURL(
host,
tab.port,
agent.name,
workspace.name,
workspace.owner_name,
tab.protocol,
);
const unavailableMessage = getUnavailableMessage({ host, agent, url });
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default bg-surface-secondary px-2 py-1 text-xs text-content-secondary">
<NetworkIcon className="size-3.5 shrink-0" />
<span className="min-w-0 truncate text-content-primary">
{tab.label}
</span>
<div className="flex-1" />
{unavailableMessage ? (
<Button
size="icon"
variant="subtle"
disabled
aria-label="Open port in new tab"
>
<ExternalLinkIcon />
</Button>
) : (
<Button size="icon" variant="subtle" asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open port in new tab"
>
<ExternalLinkIcon />
</a>
</Button>
)}
</div>
{unavailableMessage ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs text-content-secondary">
{unavailableMessage}
</div>
) : (
<WorkspaceIframe src={url} title={tab.label} />
)}
</div>
);
};
function getUnavailableMessage({
host,
agent,
url,
}: {
host: string;
agent: WorkspaceAgent;
url: string;
}): string | undefined {
if (host.trim() === "") {
return "Port previews require a wildcard access URL.";
}
if (agent.status !== "connected") {
return "Port preview will be available once the workspace agent reconnects.";
}
if (url === "#") {
return "The wildcard access URL produced an invalid preview URL. Check the deployment's wildcard access URL configuration.";
}
return undefined;
}
@@ -0,0 +1,189 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { WorkspaceApp } from "#/api/typesGenerated";
import {
MockListeningPortsResponse,
MockSharedPortsResponse,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "#/testHelpers/entities";
import { withProxyProvider } from "#/testHelpers/storybook";
import { RightPanelAddTabControl } from "./RightPanelAddTabControl";
const embeddableApp: WorkspaceApp = {
...MockWorkspaceApp,
id: "preview-app",
slug: "preview",
display_name: "Preview",
health: "healthy",
external: false,
hidden: false,
command: undefined,
};
const commandApp: WorkspaceApp = {
...MockWorkspaceApp,
id: "command-app",
slug: "claude-code",
display_name: "Claude Code",
health: "healthy",
external: false,
hidden: false,
command: "claude",
};
const externalApp: WorkspaceApp = {
...MockWorkspaceApp,
id: "docs-app",
slug: "docs",
display_name: "Documentation",
health: "healthy",
external: true,
url: "https://example.com",
open_in: "tab",
};
const meta = {
title: "pages/AgentsPage/components/RightPanel/RightPanelAddTabControl",
component: RightPanelAddTabControl,
args: {
appExperimentEnabled: true,
workspace: MockWorkspace,
agent: {
...MockWorkspaceAgent,
apps: [embeddableApp, commandApp, externalApp],
},
host: "*.apps.example.com",
isRunning: true,
onNewTerminal: fn(),
onOpenWorkspaceApp: fn(),
onOpenCommandApp: fn(),
onOpenPort: fn(),
},
parameters: {
layout: "centered",
queries: [
{
key: ["portForward", MockWorkspaceAgent.id],
data: MockListeningPortsResponse,
},
{
key: ["sharedPorts", MockWorkspace.id],
data: MockSharedPortsResponse,
},
],
},
decorators: [
withProxyProvider(),
(Story) => (
<div style={{ padding: 16 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof RightPanelAddTabControl>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const openMenu = async () => {
await userEvent.click(canvas.getByLabelText("Add panel"));
};
await openMenu();
await waitFor(() => {
expect(body.getByText("New Terminal")).toBeInTheDocument();
expect(body.getByText("Preview")).toBeInTheDocument();
expect(body.getByText("Claude Code")).toBeInTheDocument();
expect(body.getByText("Documentation")).toBeInTheDocument();
expect(body.getByText("Ports (3)")).toBeInTheDocument();
});
// Radix closes the menu after each item click, so reopen between
// callback assertions.
await userEvent.click(body.getByText("New Terminal"));
await expect(args.onNewTerminal).toHaveBeenCalledTimes(1);
await openMenu();
await userEvent.click(body.getByText("Preview"));
await expect(args.onOpenWorkspaceApp).toHaveBeenCalledWith(
expect.objectContaining({ slug: "preview" }),
);
await openMenu();
await userEvent.click(body.getByText("Claude Code"));
await expect(args.onOpenCommandApp).toHaveBeenCalledWith(
expect.objectContaining({ slug: "claude-code" }),
);
await openMenu();
await userEvent.hover(body.getByText("Ports (3)"));
await waitFor(() => {
expect(body.getByText("Listening Ports")).toBeInTheDocument();
expect(body.getByText("8080")).toBeInTheDocument();
});
await userEvent.click(body.getByText("8080"));
await expect(args.onOpenPort).toHaveBeenCalledWith(
expect.objectContaining({ port: 8080 }),
);
},
};
export const DisconnectedWorkspace: Story = {
args: {
agent: {
...MockWorkspaceAgent,
status: "disconnected",
apps: [embeddableApp, commandApp],
},
isRunning: false,
},
parameters: {
// No seeded port data: the ports query does not run while the agent
// is disconnected, so the trigger shows no count.
queries: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Add panel"));
const body = within(document.body);
await waitFor(() => {
// Creating a terminal is disabled while the workspace is not
// running, since the agent cannot accept a PTY session.
const terminalItem = body
.getByText("New Terminal")
.closest("[role=menuitem]");
expect(terminalItem).toHaveAttribute("aria-disabled", "true");
// App items render but are disabled while the workspace is not
// running.
const previewItem = body.getByText("Preview").closest("[role=menuitem]");
expect(previewItem).toHaveAttribute("aria-disabled", "true");
const commandItem = body
.getByText("Claude Code")
.closest("[role=menuitem]");
expect(commandItem).toHaveAttribute("aria-disabled", "true");
// Ports sub-trigger is disabled, with no stale port count.
const portsItem = body.getByText("Ports").closest("[role=menuitem]");
expect(portsItem).toHaveAttribute("aria-disabled", "true");
});
},
};
export const AppExperimentDisabled: Story = {
args: {
appExperimentEnabled: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText("New terminal tab")).toBeEnabled();
await expect(canvas.queryByLabelText("Add panel")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,211 @@
import {
ChevronDownIcon,
LayoutGridIcon,
PlusIcon,
SquareTerminalIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps";
import { AppLink } from "#/modules/resources/AppLink/AppLink";
import {
canShowPortForwarding,
usePortsData,
} from "#/modules/resources/usePortsData";
import { cn } from "#/utils/cn";
import type { PortSelection } from "../../utils/rightPanelTabs";
import { PortsMenuItem } from "../WorkspacePillPorts";
// usePortsData requires a workspace and agent, which are optional props on the
// parent control, so the hook lives in this conditionally rendered component.
const AgentPortsSubMenu: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
host: string;
isOpen: boolean;
isRunning: boolean;
onPortSelect: (selection: PortSelection) => void;
}> = ({ workspace, agent, host, isOpen, isRunning, onPortSelect }) => {
const portsData = usePortsData(
workspace,
agent,
isOpen && agent.status === "connected",
);
return (
<PortsMenuItem
workspace={workspace}
agent={agent}
host={host}
portsData={portsData}
isRunning={isRunning}
isBelowMd={false}
focusOnMount={false}
onPortSelect={onPortSelect}
/>
);
};
export const RightPanelAddTabControl: FC<{
appExperimentEnabled: boolean;
workspace?: Workspace;
agent?: WorkspaceAgent;
host?: string;
isRunning?: boolean;
onNewTerminal: () => void;
onOpenWorkspaceApp?: (app: WorkspaceApp) => void;
onOpenCommandApp?: (app: WorkspaceApp) => void;
onOpenPort?: (selection: PortSelection) => void;
}> = ({
appExperimentEnabled,
workspace,
agent,
host = "",
isRunning = false,
onNewTerminal,
onOpenWorkspaceApp,
onOpenCommandApp,
onOpenPort,
}) => {
const [open, setOpen] = useState(false);
const userApps = agent?.apps.filter((app) => !app.hidden) ?? [];
const canCreateTerminal =
workspace !== undefined && agent !== undefined && isRunning;
return (
<div className="flex h-6 shrink-0 items-center overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-content-secondary">
<Button
variant="subtle"
size="icon"
onClick={onNewTerminal}
disabled={!canCreateTerminal}
aria-label="New terminal tab"
title="New terminal tab"
className={cn(
"size-6 rounded-none border-0 bg-transparent p-0 text-content-secondary hover:bg-surface-secondary hover:text-content-primary",
appExperimentEnabled && "border-r border-solid border-border-default",
)}
>
<PlusIcon className="size-3.5" />
</Button>
{appExperimentEnabled && (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Add panel"
className="size-6 rounded-none border-0 bg-transparent p-0 text-content-secondary hover:bg-surface-secondary hover:text-content-primary"
>
<ChevronDownIcon
className={cn(
"size-3 transition-transform",
open && "rotate-180",
)}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
className="w-52 p-1 [&_[role=menuitem]]:py-1 [&_[role=menuitem]]:text-xs [&_img]:!size-3.5 [&_svg]:!size-3.5"
>
<DropdownMenuItem
onSelect={onNewTerminal}
disabled={!canCreateTerminal}
>
<SquareTerminalIcon />
New Terminal
</DropdownMenuItem>
{workspace && agent && userApps.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
{userApps.map((app) => {
if (app.command && onOpenCommandApp) {
return (
<DropdownMenuItem
key={app.id}
onSelect={() => onOpenCommandApp(app)}
disabled={!isRunning}
>
{app.icon ? (
<ExternalImage
src={app.icon}
alt=""
className="rounded-sm"
/>
) : (
<SquareTerminalIcon />
)}
{app.display_name ?? app.slug}
</DropdownMenuItem>
);
}
if (isWorkspaceAppEmbeddable(app) && onOpenWorkspaceApp) {
return (
<DropdownMenuItem
key={app.id}
onSelect={() => onOpenWorkspaceApp(app)}
disabled={!isRunning}
>
{app.icon ? (
<ExternalImage
src={app.icon}
alt=""
className="rounded-sm"
/>
) : (
<LayoutGridIcon />
)}
{app.display_name ?? app.slug}
</DropdownMenuItem>
);
}
return (
<AppLink
key={app.id}
workspace={workspace}
agent={agent}
app={app}
grouped
/>
);
})}
</>
)}
{workspace &&
agent &&
onOpenPort &&
canShowPortForwarding(agent, host) && (
<>
<DropdownMenuSeparator className="my-1" />
<AgentPortsSubMenu
workspace={workspace}
agent={agent}
host={host}
isOpen={open}
isRunning={isRunning}
onPortSelect={onOpenPort}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
@@ -24,6 +24,8 @@ const TERMINAL_IDLE_DETACH_MS = 30_000;
interface TerminalPanelProps {
chatId: string;
reconnectionToken?: string;
/** Command run when the PTY session is first created, such as a command app. */
initialCommand?: string;
/** Whether this terminal should hold live xterm and WebSocket resources. */
isHot?: boolean;
/**
@@ -43,6 +45,7 @@ interface TerminalPanelProps {
export const TerminalPanel: FC<TerminalPanelProps> = ({
chatId,
reconnectionToken = chatId,
initialCommand,
isHot,
autoFocus = true,
onReady,
@@ -164,6 +167,7 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
onContentReady={signalReady}
onError={handleTerminalError}
reconnectionToken={reconnectionToken}
initialCommand={initialCommand}
baseUrl={terminalConfig.baseUrl}
terminalFontFamily={terminalConfig.fontFamily}
renderer={terminalConfig.renderer}
@@ -44,13 +44,13 @@ import {
openAppInNewWindow,
} from "#/modules/apps/apps";
import { useAppLink } from "#/modules/apps/useAppLink";
import {
canShowPortForwarding,
usePortsData,
} from "#/modules/resources/usePortsData";
import { cn } from "#/utils/cn";
import { getWorkspaceStatus, StatusIcon } from "./StatusIcon";
import {
MobilePortsPanel,
PortsMenuItem,
usePortsData,
} from "./WorkspacePillPorts";
import { MobilePortsPanel, PortsMenuItem } from "./WorkspacePillPorts";
interface WorkspacePillProps {
workspace: Workspace;
@@ -86,8 +86,7 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
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 portForwardingEnabled = canShowPortForwarding(agent, host);
const userApps = agent.apps.filter((app) => !app.hidden);
@@ -10,10 +10,7 @@ import {
} 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,
@@ -27,60 +24,9 @@ import {
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,
};
};
import type { PortsData } from "#/modules/resources/usePortsData";
import { portForwardURL } from "#/utils/portForward";
import type { PortSelection } from "../utils/rightPanelTabs";
export const PortsMenuItem: FC<{
workspace: Workspace;
@@ -90,8 +36,14 @@ export const PortsMenuItem: FC<{
isRunning: boolean;
isBelowMd: boolean;
focusOnMount: boolean;
onFocusApplied: () => void;
onSelectInline: () => void;
onFocusApplied?: () => void;
onSelectInline?: () => void;
/**
* When set, selecting a port calls this instead of opening the port in a new
* browser tab. Used by the right-panel add-tab control to create a port
* preview tab.
*/
onPortSelect?: (selection: PortSelection) => void;
}> = ({
workspace,
agent,
@@ -102,6 +54,7 @@ export const PortsMenuItem: FC<{
focusOnMount,
onFocusApplied,
onSelectInline,
onPortSelect,
}) => {
const itemRef = useRef<HTMLDivElement>(null);
@@ -115,7 +68,7 @@ export const PortsMenuItem: FC<{
return;
}
itemRef.current?.focus();
onFocusApplied();
onFocusApplied?.();
}, [focusOnMount, isBelowMd, onFocusApplied]);
if (isBelowMd) {
@@ -125,7 +78,7 @@ export const PortsMenuItem: FC<{
disabled={!isRunning}
onSelect={(event) => {
event.preventDefault();
onSelectInline();
onSelectInline?.();
}}
>
<NetworkIcon className="size-3.5" />
@@ -147,6 +100,7 @@ export const PortsMenuItem: FC<{
agent={agent}
workspace={workspace}
data={portsData}
onPortSelect={onPortSelect}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -194,7 +148,8 @@ const PortsList: FC<{
agent: WorkspaceAgent;
workspace: Workspace;
data: PortsData;
}> = ({ host, agent, workspace, data }) => {
onPortSelect?: (selection: PortSelection) => void;
}> = ({ host, agent, workspace, data, onPortSelect }) => {
const route = `/@${workspace.owner_name}/${workspace.name}`;
const { listeningPorts, sharedPorts, privateListeningPorts, protocol } = data;
@@ -217,6 +172,7 @@ const PortsList: FC<{
workspaceName={workspace.name}
ownerName={workspace.owner_name}
protocol={protocol}
onPortSelect={onPortSelect}
/>
))}
@@ -245,6 +201,7 @@ const PortsList: FC<{
agentName={agent.name}
workspaceName={workspace.name}
ownerName={workspace.owner_name}
onPortSelect={onPortSelect}
/>
))}
</>
@@ -268,7 +225,38 @@ const ListeningPortItem: FC<{
workspaceName: string;
ownerName: string;
protocol: "http" | "https";
}> = ({ port, host, agentName, workspaceName, ownerName, protocol }) => {
onPortSelect?: (selection: PortSelection) => void;
}> = ({
port,
host,
agentName,
workspaceName,
ownerName,
protocol,
onPortSelect,
}) => {
if (onPortSelect) {
return (
<DropdownMenuItem
onSelect={() =>
onPortSelect({
label: `Port ${port.port}`,
port: port.port,
protocol,
})
}
>
<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>
)}
</DropdownMenuItem>
);
}
const url = portForwardURL(
host,
port.port,
@@ -299,7 +287,34 @@ const SharedPortItem: FC<{
agentName: string;
workspaceName: string;
ownerName: string;
}> = ({ share, host, agentName, workspaceName, ownerName }) => {
onPortSelect?: (selection: PortSelection) => void;
}> = ({ share, host, agentName, workspaceName, ownerName, onPortSelect }) => {
const ShareIcon =
share.share_level === "public"
? LockOpenIcon
: share.share_level === "organization"
? BuildingIcon
: LockIcon;
if (onPortSelect) {
return (
<DropdownMenuItem
onSelect={() =>
onPortSelect({
label: `Port ${share.port}`,
port: share.port,
protocol: share.protocol,
})
}
>
<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>
</DropdownMenuItem>
);
}
const url = portForwardURL(
host,
share.port,
@@ -308,12 +323,6 @@ const SharedPortItem: FC<{
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">
@@ -1,4 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "#/api/typesGenerated";
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "#/testHelpers/entities";
import {
clearPersistedRightPanelState,
getPersistedDefaultTerminalHidden,
@@ -7,17 +17,161 @@ import {
savePersistedDefaultTerminalHidden,
savePersistedRightPanelTabs,
} from "./rightPanelTabStorage";
import type { UserRightPanelTab } from "./rightPanelTabs";
import {
type UserRightPanelTab,
validateUserRightPanelTabs,
} from "./rightPanelTabs";
type TerminalRightPanelTab = Extract<UserRightPanelTab, { kind: "terminal" }>;
const terminalTab = (
overrides: Partial<UserRightPanelTab> = {},
): UserRightPanelTab => ({
overrides: Partial<TerminalRightPanelTab> = {},
): TerminalRightPanelTab => ({
id: "terminal-2",
kind: "terminal",
reconnectionToken: "11111111-1111-4111-8111-111111111111",
...overrides,
});
describe("right-panel tab validation", () => {
const tabs: UserRightPanelTab[] = [
terminalTab(),
{
id: "app-preview",
kind: "workspace_app",
label: "Preview",
agentId: MockWorkspaceAgent.id,
appId: MockWorkspaceApp.id,
},
{
id: "port-3000",
kind: "port",
label: "Port 3000",
agentId: MockWorkspaceAgent.id,
port: 3000,
protocol: "http",
},
];
it("keeps tabs that still match the workspace and wildcard host", () => {
expect(
validateUserRightPanelTabs(tabs, {
workspace: MockWorkspace,
workspaceAgent: MockWorkspaceAgent,
wildcardHostname: "*.apps.example.com",
}),
).toEqual(tabs);
});
it("drops terminal tabs when there is no workspace agent", () => {
const validated = validateUserRightPanelTabs(tabs, {
workspace: MockWorkspace,
workspaceAgent: undefined,
wildcardHostname: "*.apps.example.com",
});
expect(validated).toEqual(tabs.filter((tab) => tab.kind !== "terminal"));
});
it("drops port tabs when wildcard access is unavailable", () => {
const validated = validateUserRightPanelTabs(tabs, {
workspace: MockWorkspace,
workspaceAgent: MockWorkspaceAgent,
wildcardHostname: "",
});
expect(validated).toEqual(tabs.filter((tab) => tab.kind !== "port"));
});
it("drops port tabs when the agent no longer exposes the port forwarding helper", () => {
const agent: WorkspaceAgent = {
...MockWorkspaceAgent,
display_apps: MockWorkspaceAgent.display_apps.filter(
(app) => app !== "port_forwarding_helper",
),
};
const validated = validateUserRightPanelTabs(tabs, {
workspace: buildWorkspace([agent]),
workspaceAgent: agent,
wildcardHostname: "*.apps.example.com",
});
expect(validated).toEqual(tabs.filter((tab) => tab.kind !== "port"));
});
it("drops app tabs when the app no longer exists", () => {
const validated = validateUserRightPanelTabs(
[
{
id: "missing-app",
kind: "workspace_app",
label: "Missing",
agentId: MockWorkspaceAgent.id,
appId: "missing-app",
},
],
{
workspace: MockWorkspace,
workspaceAgent: MockWorkspaceAgent,
wildcardHostname: "*.apps.example.com",
},
);
expect(validated).toEqual([]);
});
it("drops app tabs when the app is no longer embeddable", () => {
const commandApp = buildApp("command-app", { command: "run-preview" });
const workspace = buildWorkspace([buildAgent("agent-1", [commandApp])]);
const appTab: UserRightPanelTab = {
id: "command-app-tab",
kind: "workspace_app",
label: "Command",
agentId: "agent-1",
appId: "command-app",
};
const validated = validateUserRightPanelTabs([appTab], {
workspace,
workspaceAgent: workspace.latest_build.resources[0].agents?.[0],
wildcardHostname: "*.apps.example.com",
});
expect(validated).toEqual([]);
});
});
function buildWorkspace(resourceAgents: readonly WorkspaceAgent[]): Workspace {
const resourceTemplate = MockWorkspace.latest_build.resources[0];
return {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: [{ ...resourceTemplate, agents: resourceAgents }],
},
};
}
function buildAgent(id: string, apps: WorkspaceApp[]): WorkspaceAgent {
return { ...MockWorkspaceAgent, id, name: id, apps };
}
function buildApp(
id: string,
overrides: Partial<WorkspaceApp> = {},
): WorkspaceApp {
return {
...MockWorkspaceApp,
id,
slug: id,
display_name: id,
health: "healthy",
statuses: [],
...overrides,
};
}
describe("right-panel tab storage", () => {
beforeEach(() => {
localStorage.clear();
@@ -32,6 +186,21 @@ describe("right-panel tab storage", () => {
expect(getPersistedRightPanelTabs("chat-2")).toEqual([]);
});
it("persists command-app terminal tabs", () => {
const tabs: UserRightPanelTab[] = [
terminalTab({
id: "terminal-claude",
label: "Claude Code",
initialCommand: "claude",
sourceAppId: MockWorkspaceApp.id,
}),
];
savePersistedRightPanelTabs("chat-1", tabs);
expect(getPersistedRightPanelTabs("chat-1")).toEqual(tabs);
});
it("clears all persisted right-panel state for a chat", () => {
const tabs: UserRightPanelTab[] = [terminalTab()];
@@ -48,10 +217,61 @@ describe("right-panel tab storage", () => {
expect(getPersistedDefaultTerminalHidden("chat-2")).toBe(true);
});
it("persists workspace_app tabs", () => {
const tabs: UserRightPanelTab[] = [
{
id: "app-preview",
kind: "workspace_app",
label: "Preview",
agentId: MockWorkspaceAgent.id,
appId: MockWorkspaceApp.id,
},
];
savePersistedRightPanelTabs("chat-1", tabs);
expect(getPersistedRightPanelTabs("chat-1")).toEqual(tabs);
});
it("persists port tabs", () => {
const tabs: UserRightPanelTab[] = [
{
id: "port-3000",
kind: "port",
label: "Port 3000",
agentId: MockWorkspaceAgent.id,
port: 3000,
protocol: "http",
},
];
savePersistedRightPanelTabs("chat-1", tabs);
expect(getPersistedRightPanelTabs("chat-1")).toEqual(tabs);
});
it("ignores invalid stored values", () => {
localStorage.setItem(
`${rightPanelTabStorageKeyPrefix}chat-1`,
JSON.stringify([{ id: "bad-tab", kind: "terminal" }]),
JSON.stringify([{ id: "bad-tab", kind: "port" }]),
);
expect(getPersistedRightPanelTabs("chat-1")).toEqual([]);
});
it("ignores port tabs with out-of-range ports", () => {
localStorage.setItem(
`${rightPanelTabStorageKeyPrefix}chat-1`,
JSON.stringify([
{
id: "port-70000",
kind: "port",
label: "Port 70000",
agentId: MockWorkspaceAgent.id,
port: 70000,
protocol: "http",
},
]),
);
expect(getPersistedRightPanelTabs("chat-1")).toEqual([]);
@@ -1,7 +1,53 @@
export type UserRightPanelTab = {
id: string;
kind: "terminal";
reconnectionToken: string;
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentPortShareProtocol,
} from "#/api/typesGenerated";
import { isWorkspaceAppEmbeddable } from "#/modules/apps/apps";
import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps";
import { canShowPortForwarding } from "#/modules/resources/usePortsData";
import { findWorkspaceAgent } from "#/utils/workspace";
export type PortSelection = {
label: string;
port: number;
protocol: WorkspaceAgentPortShareProtocol;
};
export type UserRightPanelTab =
| {
id: string;
kind: "terminal";
label?: string;
reconnectionToken: string;
/**
* Command run when the PTY session is first created. The backend only
* runs it for a fresh reconnect token, so reattaching does not re-run it.
*/
initialCommand?: string;
/** ID of the command app that opened this terminal, used to dedupe tabs. */
sourceAppId?: string;
}
| {
id: string;
kind: "workspace_app";
label: string;
appId: string;
agentId: string;
}
| {
id: string;
kind: "port";
label: string;
agentId: string;
port: number;
protocol: WorkspaceAgentPortShareProtocol;
};
type ValidateUserRightPanelTabsOptions = {
workspace: Workspace | undefined;
workspaceAgent: WorkspaceAgent | undefined;
wildcardHostname: string;
};
export function isUserRightPanelTab(
@@ -16,8 +62,66 @@ export function isUserRightPanelTab(
}
if (record.kind === "terminal") {
return typeof record.reconnectionToken === "string";
return (
typeof record.reconnectionToken === "string" &&
(record.label === undefined || typeof record.label === "string") &&
(record.initialCommand === undefined ||
typeof record.initialCommand === "string") &&
(record.sourceAppId === undefined ||
typeof record.sourceAppId === "string")
);
}
if (record.kind === "workspace_app") {
return (
typeof record.label === "string" &&
typeof record.appId === "string" &&
typeof record.agentId === "string"
);
}
if (record.kind === "port") {
return (
typeof record.label === "string" &&
typeof record.agentId === "string" &&
typeof record.port === "number" &&
Number.isInteger(record.port) &&
record.port >= 1 &&
record.port <= 65535 &&
(record.protocol === "http" || record.protocol === "https")
);
}
return false;
}
export function validateUserRightPanelTabs(
tabs: readonly UserRightPanelTab[],
{
workspace,
workspaceAgent,
wildcardHostname,
}: ValidateUserRightPanelTabsOptions,
): UserRightPanelTab[] {
return tabs.filter((tab) => {
if (tab.kind === "terminal") {
return workspace !== undefined && workspaceAgent !== undefined;
}
if (!workspace) {
return false;
}
if (tab.kind === "workspace_app") {
const app = findWorkspaceAppWithAgent(workspace, tab.agentId, tab.appId);
return app !== undefined && isWorkspaceAppEmbeddable(app);
}
// Mirror the add-menu gate so a persisted port tab disappears when
// the agent stops exposing the port forwarding helper.
const agent = findWorkspaceAgent(workspace, tab.agentId);
return (
agent !== undefined && canShowPortForwarding(agent, wildcardHostname)
);
});
}