From 5ee1946b67b91a4f3a84b9a43c3d9d4b2632bfed Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:59:21 +1000 Subject: [PATCH] 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 --- site/src/api/queries/workspaces.ts | 8 + site/src/modules/resources/AgentRow.tsx | 18 +- .../modules/resources/PortForwardButton.tsx | 34 +- site/src/modules/resources/usePortsData.ts | 79 +++++ .../pages/AgentsPage/AgentChatPageView.tsx | 293 ++++++++++++++---- .../RightPanel/PortPreviewPanel.stories.tsx | 98 ++++++ .../RightPanel/PortPreviewPanel.tsx | 85 +++++ .../RightPanelAddTabControl.stories.tsx | 189 +++++++++++ .../RightPanel/RightPanelAddTabControl.tsx | 211 +++++++++++++ .../AgentsPage/components/TerminalPanel.tsx | 4 + .../AgentsPage/components/WorkspacePill.tsx | 13 +- .../components/WorkspacePillPorts.tsx | 149 ++++----- .../AgentsPage/utils/rightPanelTabs.test.ts | 228 +++++++++++++- .../pages/AgentsPage/utils/rightPanelTabs.ts | 114 ++++++- 14 files changed, 1347 insertions(+), 176 deletions(-) create mode 100644 site/src/modules/resources/usePortsData.ts create mode 100644 site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index ea6ec316ad..d5d0fd5476 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -17,6 +17,7 @@ import type { WorkspaceAgent, WorkspaceAgentDevcontainer, WorkspaceAgentListContainersResponse, + WorkspaceAgentListeningPortsResponse, WorkspaceAgentLog, WorkspaceBuild, WorkspaceBuildParameter, @@ -497,6 +498,13 @@ export const agentLogs = (agentId: string) => { } satisfies UseQueryOptions; }; +export const agentListeningPorts = (agentId: string) => { + return { + queryKey: ["portForward", agentId], + queryFn: () => API.getAgentListeningPorts(agentId), + } satisfies UseQueryOptions; +}; + // workspace usage options interface WorkspaceUsageOptions { usageApp: UsageAppName; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 283aab474f..203bc314ae 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -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 = ({ workspaceOwnerUsername={workspace.owner_name} /> )} - {proxy.preferredWildcardHostname !== "" && - agent.display_apps.includes("port_forwarding_helper") && ( - - )} + {canShowPortForwarding(agent, proxy.preferredWildcardHostname) && ( + + )} diff --git a/site/src/modules/resources/PortForwardButton.tsx b/site/src/modules/resources/PortForwardButton.tsx index 073f0f06e1..5e88c50b04 100644 --- a/site/src/modules/resources/PortForwardButton.tsx +++ b/site/src/modules/resources/PortForwardButton.tsx @@ -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 = ({ }) => { 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 ( @@ -193,13 +184,10 @@ export const PortForwardPopoverView: FC = ({ }); 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 = ({ {canSharePorts && (
- {filteredSharedPorts?.map((share) => { + {sharedPorts.map((share) => { const url = portForwardURL( host, share.port, diff --git a/site/src/modules/resources/usePortsData.ts b/site/src/modules/resources/usePortsData.ts new file mode 100644 index 0000000000..971ada5cbf --- /dev/null +++ b/site/src/modules/resources/usePortsData.ts @@ -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, + }; +}; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index a6db3f0d63..7dadaf5988 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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; editorInitialValue: string; @@ -209,39 +219,89 @@ interface AgentChatPageViewProps { lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; } -interface UserTerminalTabContentProps { +const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => ( +
+ {message} +
+); + +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 = ({ +const UserTabContent: FC = ({ tab, chatId, workspace, workspaceAgent, - activeTabId, - pendingTabId, - isPanelVisible, - onReady, + wildcardHostname, + sidebarVisible, + isActive, + isPending, + onTerminalReady, }) => { - const isActive = activeTabId === tab.id; - return ( - onReady(tab.id)} - workspace={workspace} - workspaceAgent={workspaceAgent} - /> - ); + switch (tab.kind) { + case "terminal": + return workspace && workspaceAgent ? ( + onTerminalReady(tab.id)} + workspace={workspace} + workspaceAgent={workspaceAgent} + /> + ) : ( + + ); + case "workspace_app": { + if (!workspace) { + return null; + } + const app = findWorkspaceAppWithAgent(workspace, tab.agentId, tab.appId); + if (!app || !isWorkspaceAppEmbeddable(app)) { + return ( + + ); + } + return ( + + ); + } + case "port": { + if (!workspace) { + return null; + } + const agent = findWorkspaceAgent(workspace, tab.agentId); + if (!agent) { + return ( + + ); + } + return ( + + ); + } + default: { + const _exhaustive: never = tab; + return _exhaustive; + } + } }; export const AgentChatPageView: FC = ({ @@ -316,6 +376,12 @@ export const AgentChatPageView: FC = ({ 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 = ({ 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 = ({ 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 = ({ { 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ /> ); default: { - const userTab = visibleUserTabs.find((tab) => tab.id === tabId); - return userTab && workspace && workspaceAgent ? ( - tab.id === tabId, + ); + return userTab ? ( + ) : null; } @@ -594,7 +771,7 @@ export const AgentChatPageView: FC = ({ 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 = ({ onActiveTabChange={handleActiveTabChange} tabs={sidebarTabs} addTabControl={ - + } onClose={() => onSetShowSidebarPanel(false)} isExpanded={visualExpanded} diff --git a/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.stories.tsx new file mode 100644 index 0000000000..0b1dd0b4db --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.stories.tsx @@ -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 = { + 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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.tsx new file mode 100644 index 0000000000..e69cb4e239 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/PortPreviewPanel.tsx @@ -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; +}> = ({ 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 ( +
+
+ + + {tab.label} + +
+ {unavailableMessage ? ( + + ) : ( + + )} +
+ {unavailableMessage ? ( +
+ {unavailableMessage} +
+ ) : ( + + )} +
+ ); +}; + +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; +} diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.stories.tsx new file mode 100644 index 0000000000..94a4f37195 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx new file mode 100644 index 0000000000..cc47f2b067 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanelAddTabControl.tsx @@ -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 ( + + ); +}; + +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 ( +
+ + {appExperimentEnabled && ( + + + + + + + + New Terminal + + + {workspace && agent && userApps.length > 0 && ( + <> + + {userApps.map((app) => { + if (app.command && onOpenCommandApp) { + return ( + onOpenCommandApp(app)} + disabled={!isRunning} + > + {app.icon ? ( + + ) : ( + + )} + {app.display_name ?? app.slug} + + ); + } + if (isWorkspaceAppEmbeddable(app) && onOpenWorkspaceApp) { + return ( + onOpenWorkspaceApp(app)} + disabled={!isRunning} + > + {app.icon ? ( + + ) : ( + + )} + {app.display_name ?? app.slug} + + ); + } + return ( + + ); + })} + + )} + + {workspace && + agent && + onOpenPort && + canShowPortForwarding(agent, host) && ( + <> + + + + )} + + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/TerminalPanel.tsx b/site/src/pages/AgentsPage/components/TerminalPanel.tsx index 1f52eeb50c..f28444afaa 100644 --- a/site/src/pages/AgentsPage/components/TerminalPanel.tsx +++ b/site/src/pages/AgentsPage/components/TerminalPanel.tsx @@ -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 = ({ chatId, reconnectionToken = chatId, + initialCommand, isHot, autoFocus = true, onReady, @@ -164,6 +167,7 @@ export const TerminalPanel: FC = ({ onContentReady={signalReady} onError={handleTerminalError} reconnectionToken={reconnectionToken} + initialCommand={initialCommand} baseUrl={terminalConfig.baseUrl} terminalFontFamily={terminalConfig.fontFamily} renderer={terminalConfig.renderer} diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index 98168f21e4..1a194ba3da 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -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 = ({ 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); diff --git a/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx b/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx index 2d4a6f8742..6ecc228f0b 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePillPorts.tsx @@ -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(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?.(); }} > @@ -147,6 +100,7 @@ export const PortsMenuItem: FC<{ agent={agent} workspace={workspace} data={portsData} + onPortSelect={onPortSelect} /> @@ -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 ( + + onPortSelect({ + label: `Port ${port.port}`, + port: port.port, + protocol, + }) + } + > + + {port.port} + {port.process_name !== "" && ( + + {port.process_name} + + )} + + ); + } + 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 ( + + onPortSelect({ + label: `Port ${share.port}`, + port: share.port, + protocol: share.protocol, + }) + } + > + + {share.port} + + {share.share_level} + + + ); + } + 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 ( diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts index 49fe7828d0..ee449e9564 100644 --- a/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.test.ts @@ -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; const terminalTab = ( - overrides: Partial = {}, -): UserRightPanelTab => ({ + overrides: Partial = {}, +): 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 { + 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([]); diff --git a/site/src/pages/AgentsPage/utils/rightPanelTabs.ts b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts index d3e82ff408..78b6cdbab4 100644 --- a/site/src/pages/AgentsPage/utils/rightPanelTabs.ts +++ b/site/src/pages/AgentsPage/utils/rightPanelTabs.ts @@ -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) + ); + }); +}