mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user