feat(site): expose workspace apps in chat workspace pill (#24295)

This commit is contained in:
Danielle Maywood
2026-04-15 10:13:13 +01:00
committed by GitHub
parent 44f361d1a5
commit e317f3b239
11 changed files with 717 additions and 242 deletions
@@ -640,11 +640,13 @@ export const CompletedWithDiffPanel: Story = {
// Verify menu items are rendered.
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Open in Cursor")).toBeInTheDocument();
expect(body.getByText("Archive Agent")).toBeInTheDocument();
});
expect(body.getByText("Open in VS Code")).toBeInTheDocument();
expect(body.getByText("View Workspace")).toBeInTheDocument();
expect(body.getByText("Archive Agent")).toBeInTheDocument();
// Workspace items moved to the workspace pill popover.
expect(body.queryByText("Open in Cursor")).not.toBeInTheDocument();
expect(body.queryByText("Open in VS Code")).not.toBeInTheDocument();
expect(body.queryByText("View Workspace")).not.toBeInTheDocument();
expect(body.queryByText("Copy SSH Command")).not.toBeInTheDocument();
},
};
+1 -76
View File
@@ -7,9 +7,8 @@ import {
useQueryClient,
} from "react-query";
import { useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import { API, watchWorkspace } from "#/api/api";
import { watchWorkspace } from "#/api/api";
import { isApiError } from "#/api/errors";
import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits";
import {
@@ -31,11 +30,6 @@ import { workspaceById, workspaceByIdKey } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart } from "#/api/typesGenerated";
import { useProxy } from "#/contexts/ProxyContext";
import {
getTerminalHref,
getVSCodeHref,
openAppInNewWindow,
} from "#/modules/apps/apps";
import { isMobileViewport } from "#/utils/mobile";
import { pageTitle } from "#/utils/page";
import { rewriteLocalhostURL } from "#/utils/portForward";
@@ -1069,75 +1063,11 @@ const AgentChatPage: FC = () => {
);
const parentChat = parentChatQuery.data;
const workspaceRoute = workspace
? `/@${workspace.owner_name}/${workspace.name}`
: null;
const canOpenWorkspace = Boolean(workspaceRoute);
const canOpenEditors = Boolean(workspace && workspaceAgent);
const terminalHref =
workspace && workspaceAgent
? getTerminalHref({
username: workspace.owner_name,
workspace: workspace.name,
agent: workspaceAgent.name,
})
: null;
const sshCommand =
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
// See mutation destructuring comment above (React Compiler).
const { mutate: generateKey } = useMutation({
mutationFn: () => API.getApiKey(),
});
const handleOpenInEditor = (editor: "cursor" | "vscode") => {
if (!workspace || !workspaceAgent) {
return;
}
// Prefer the active git repo root so VS Code opens to the
// actual project directory, falling back to the agent's
// configured directory.
const repoRoots = Array.from(gitWatcher.repositories.keys()).sort();
const folder = repoRoots[0] ?? workspaceAgent.expanded_directory;
generateKey(undefined, {
onSuccess: ({ key }) => {
location.href = getVSCodeHref(editor, {
owner: workspace.owner_name,
workspace: workspace.name,
token: key,
agent: workspaceAgent.name,
folder,
chatId: agentId,
});
},
onError: () => {
toast.error(
editor === "cursor"
? "Failed to open in Cursor."
: "Failed to open in VS Code.",
);
},
});
};
const handleViewWorkspace = () => {
if (!workspaceRoute) {
return;
}
window.open(workspaceRoute, "_blank");
};
const handleOpenTerminal = () => {
if (!terminalHref) {
return;
}
openAppInNewWindow(terminalHref);
};
const handleArchiveAgentAction = () => {
if (!agentId || isArchived) {
return;
@@ -1261,12 +1191,7 @@ const AgentChatPage: FC = () => {
prNumber={prNumber}
diffStatusData={chatQuery.data?.diff_status}
gitWatcher={gitWatcher}
canOpenEditors={canOpenEditors}
canOpenWorkspace={canOpenWorkspace}
sshCommand={sshCommand}
handleOpenInEditor={handleOpenInEditor}
handleViewWorkspace={handleViewWorkspace}
handleOpenTerminal={handleOpenTerminal}
handleCommit={handleCommit}
handleInterrupt={handleInterrupt}
handleDeleteQueuedMessage={handleDeleteQueuedMessage}
@@ -137,12 +137,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
typeof AgentChatPageView
>["diffStatusData"],
gitWatcher: buildGitWatcher(),
canOpenEditors: false,
canOpenWorkspace: false,
sshCommand: undefined as string | undefined,
handleOpenInEditor: fn(),
handleViewWorkspace: fn(),
handleOpenTerminal: fn(),
handleCommit: fn(),
handleInterrupt: fn(),
handleDeleteQueuedMessage: fn(),
@@ -367,12 +362,11 @@ export const NoModelOptions: Story = {
),
};
/** Top bar has workspace action buttons visible. */
export const WithWorkspaceActions: Story = {
export const WithWorkspace: Story = {
render: () => (
<StoryAgentChatPageView
canOpenEditors
canOpenWorkspace
workspace={MockWorkspace}
workspaceAgent={MockWorkspaceAgent}
sshCommand="ssh coder.workspace"
/>
),
+20 -78
View File
@@ -1,10 +1,4 @@
import {
ArchiveIcon,
MonitorDotIcon,
MonitorIcon,
MonitorPauseIcon,
MonitorXIcon,
} from "lucide-react";
import { ArchiveIcon } from "lucide-react";
import {
type FC,
@@ -20,11 +14,6 @@ import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import {
type DisplayWorkspaceStatusType,
getDisplayWorkspaceStatus,
} from "#/utils/workspace";
import {
AgentChatInput,
type ChatMessageInputRef,
@@ -43,6 +32,7 @@ import { ChatTopBar } from "./components/ChatTopBar";
import { GitPanel } from "./components/GitPanel/GitPanel";
import { RightPanel } from "./components/RightPanel/RightPanel";
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
import { TerminalPanel } from "./components/TerminalPanel";
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
@@ -132,12 +122,7 @@ interface AgentChatPageViewProps {
};
// Workspace action handlers.
canOpenEditors: boolean;
canOpenWorkspace: boolean;
sshCommand: string | undefined;
handleOpenInEditor: (editor: "cursor" | "vscode") => void;
handleViewWorkspace: () => void;
handleOpenTerminal: () => void;
handleCommit: (repoRoot: string) => void;
// Chat action handlers.
@@ -206,12 +191,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
prNumber,
diffStatusData,
gitWatcher,
canOpenEditors,
canOpenWorkspace,
sshCommand,
handleOpenInEditor,
handleViewWorkspace,
handleOpenTerminal,
handleCommit,
handleInterrupt,
handleDeleteQueuedMessage,
@@ -275,48 +255,29 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
// 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 = (() => {
const repoRoots = Array.from(gitWatcher?.repositories.keys() ?? []).sort();
return repoRoots[0] || workspaceAgent?.expanded_directory;
})();
const workspaceRoute = workspace
? `/@${workspace.owner_name}/${workspace.name}`
: undefined;
const attachedWorkspace = (() => {
if (!workspace || !workspaceRoute) return undefined;
let { type, text } = getDisplayWorkspaceStatus(
workspace.latest_build.status,
workspace.latest_build.job,
const { effectiveType, statusLabel } = getWorkspaceStatus(
workspace,
workspaceAgent,
);
const agentPreparing =
workspace.latest_build.status === "running" &&
(workspaceAgent?.lifecycle_state === "created" ||
workspaceAgent?.lifecycle_state === "starting");
const agentStartupFailed =
workspace.latest_build.status === "running" &&
(workspaceAgent?.lifecycle_state === "start_error" ||
workspaceAgent?.lifecycle_state === "start_timeout");
if (agentPreparing) {
type = "active";
text = "Preparing";
} else if (agentStartupFailed) {
type = "warning";
text = "Startup failed";
}
const effectiveType = workspace.health.healthy ? type : "warning";
const statusLabel = workspace.health.healthy
? `Workspace ${text.toLowerCase()}`
: `Workspace ${text.toLowerCase()} (unhealthy)`;
const iconCls = "size-3";
const statusIconMap: Record<DisplayWorkspaceStatusType, React.ReactNode> = {
success: <MonitorIcon className={iconCls} />,
active: <MonitorDotIcon className={iconCls} />,
inactive: <MonitorPauseIcon className={iconCls} />,
error: <MonitorXIcon className={iconCls} />,
danger: <MonitorXIcon className={iconCls} />,
warning: <MonitorXIcon className={iconCls} />,
};
const statusIcon = <StatusIcon type={effectiveType} />;
return {
name: workspace.name,
route: workspaceRoute,
statusIcon: statusIconMap[effectiveType],
statusIcon,
statusLabel,
};
})();
@@ -357,14 +318,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
showSidebarPanel,
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={
@@ -454,7 +407,12 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
lastInjectedContext={lastInjectedContext}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatId={agentId}
sshCommand={sshCommand}
attachedWorkspace={attachedWorkspace}
folder={preferredFolder}
/>
</div>
</div>
@@ -568,14 +526,6 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onRegenerateTitle={() => {}}
@@ -646,14 +596,6 @@ export const AgentChatPageNotFoundView: FC<AgentChatPageNotFoundViewProps> = ({
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onRegenerateTitle={() => {}}
@@ -67,6 +67,7 @@ import { ContextUsageIndicator } from "./ContextUsageIndicator";
import { ImageLightbox } from "./ImageLightbox";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
import { WorkspacePill } from "./WorkspacePill";
export {
ImageThumbnail,
@@ -148,7 +149,12 @@ interface AgentChatInputProps {
selectedMCPServerIds?: readonly string[];
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
workspace?: TypesGen.Workspace;
workspaceAgent?: TypesGen.WorkspaceAgent;
chatId?: string;
sshCommand?: string;
attachedWorkspace?: AttachedWorkspaceInfo;
folder?: string;
}
export interface AttachedWorkspaceInfo {
@@ -285,7 +291,12 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
selectedMCPServerIds,
onMCPSelectionChange,
onMCPAuthComplete,
workspace,
workspaceAgent,
chatId,
sshCommand,
attachedWorkspace,
folder,
}) => {
const [chatFullWidth] = useChatFullWidth();
const internalRef = useRef<ChatMessageInputRef>(null);
@@ -395,7 +406,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
// Ordered list of active tool badge data so we can determine
// which ones ended up in the overflow popover.
const allBadges: ToolBadgeData[] = [];
if (attachedWorkspace) {
// When workspace data is available, WorkspacePill handles
// the display (including app dropdown). Otherwise fall back
// to the simple attached-workspace ToolBadge.
if (!(workspace && workspaceAgent && chatId) && attachedWorkspace) {
allBadges.push({ kind: "attached-workspace", ...attachedWorkspace });
}
if (selectedWorkspace && onWorkspaceChange) {
@@ -918,6 +932,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
* hide and reorder via CSS. The pill is invisible
* when there's no overflow but still occupies
* layout space, preventing measurement flicker. */}
{workspace && workspaceAgent && chatId && (
<WorkspacePill
workspace={workspace}
agent={workspaceAgent}
chatId={chatId}
sshCommand={sshCommand}
folder={folder}
/>
)}
<div
ref={badgeContainerRef}
className="flex min-w-0 items-center gap-1 overflow-hidden"
@@ -184,7 +184,12 @@ interface ChatPageInputProps {
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
workspace?: TypesGen.Workspace;
workspaceAgent?: TypesGen.WorkspaceAgent;
chatId?: string;
sshCommand?: string;
attachedWorkspace?: AttachedWorkspaceInfo;
folder?: string;
}
export const ChatPageInput: FC<ChatPageInputProps> = ({
@@ -222,7 +227,12 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onMCPSelectionChange,
onMCPAuthComplete,
lastInjectedContext,
workspace,
workspaceAgent,
chatId,
sshCommand,
attachedWorkspace,
folder,
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
@@ -396,7 +406,12 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatId={chatId}
sshCommand={sshCommand}
attachedWorkspace={attachedWorkspace}
folder={folder}
/>
);
@@ -8,14 +8,6 @@ const defaultProps = {
showSidebarPanel: false,
onToggleSidebar: fn(),
},
workspace: {
canOpenEditors: true,
canOpenWorkspace: true,
onOpenInEditor: fn(),
onViewWorkspace: fn(),
onOpenTerminal: fn(),
sshCommand: "ssh main.my-workspace.admin.coder",
},
onArchiveAgent: fn(),
onArchiveAndDeleteWorkspace: fn(),
onRegenerateTitle: fn(),
@@ -3,20 +3,15 @@ import {
ArchiveRestoreIcon,
ArrowLeftIcon,
ChevronRightIcon,
CopyIcon,
EllipsisIcon,
ExternalLinkIcon,
MonitorIcon,
PanelLeftIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
TerminalIcon,
Trash2Icon,
WandSparklesIcon,
} from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDiffStatus } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
@@ -38,20 +33,10 @@ interface SidebarPanelState {
onToggleSidebar: () => void;
}
interface WorkspaceActions {
canOpenEditors: boolean;
canOpenWorkspace: boolean;
onOpenInEditor: (editor: "cursor" | "vscode") => void;
onViewWorkspace: () => void;
onOpenTerminal: () => void;
sshCommand: string | undefined;
}
type ChatTopBarProps = {
chatTitle?: string;
parentChat?: TypesGen.Chat;
panel: SidebarPanelState;
workspace: WorkspaceActions;
onArchiveAgent: () => void;
onUnarchiveAgent: () => void;
onArchiveAndDeleteWorkspace: () => void;
@@ -69,7 +54,6 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
chatTitle,
parentChat,
panel,
workspace,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
@@ -206,58 +190,8 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
// You can think of the web terminal as an editor if you squint.
disabled={!workspace.canOpenEditors}
onSelect={workspace.onOpenTerminal}
>
<TerminalIcon className="h-3.5 w-3.5" />
Open Terminal
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.sshCommand}
onSelect={async () => {
if (!workspace.sshCommand) return;
try {
await navigator.clipboard.writeText(workspace.sshCommand);
toast.success("SSH command copied to clipboard");
} catch {
toast.error("Failed to copy SSH command");
}
}}
>
<CopyIcon className="h-3.5 w-3.5" />
Copy SSH Command
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
{!isArchived && (
<>
<DropdownMenuSeparator />
{onRegenerateTitle && (
<>
<DropdownMenuItem
@@ -0,0 +1,64 @@
import {
MonitorDotIcon,
MonitorIcon,
MonitorPauseIcon,
MonitorXIcon,
} from "lucide-react";
import type { FC } from "react";
import type { Workspace, WorkspaceAgent } from "#/api/typesGenerated";
import {
type DisplayWorkspaceStatusType,
getDisplayWorkspaceStatus,
} from "#/utils/workspace";
const iconMap: Record<
DisplayWorkspaceStatusType,
FC<{ className?: string }>
> = {
success: MonitorIcon,
active: MonitorDotIcon,
inactive: MonitorPauseIcon,
error: MonitorXIcon,
danger: MonitorXIcon,
warning: MonitorXIcon,
};
export const StatusIcon: FC<{
type: DisplayWorkspaceStatusType;
className?: string;
}> = ({ type, className = "size-3" }) => {
const Icon = iconMap[type];
return <Icon className={className} />;
};
export function getWorkspaceStatus(
workspace: Workspace,
agent?: WorkspaceAgent | null,
): { effectiveType: DisplayWorkspaceStatusType; statusLabel: string } {
let { type, text } = getDisplayWorkspaceStatus(
workspace.latest_build.status,
workspace.latest_build.job,
);
const agentPreparing =
workspace.latest_build.status === "running" &&
(agent?.lifecycle_state === "created" ||
agent?.lifecycle_state === "starting");
const agentStartupFailed =
workspace.latest_build.status === "running" &&
(agent?.lifecycle_state === "start_error" ||
agent?.lifecycle_state === "start_timeout");
if (agentPreparing) {
type = "active";
text = "Preparing";
} else if (agentStartupFailed) {
type = "warning";
text = "Startup failed";
}
const effectiveType = workspace.health.healthy ? type : "warning";
const statusLabel = workspace.health.healthy
? `Workspace ${text.toLowerCase()}`
: `Workspace ${text.toLowerCase()} (unhealthy)`;
return { effectiveType, statusLabel };
}
@@ -0,0 +1,282 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { WorkspaceApp } from "#/api/typesGenerated";
import {
MockStoppedWorkspace,
MockWorkspace,
MockWorkspaceAgent,
} from "#/testHelpers/entities";
import { withProxyProvider } from "#/testHelpers/storybook";
import { WorkspacePill } from "./WorkspacePill";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const defaultProps = {
chatId: "chat-abc-123",
} satisfies Partial<React.ComponentProps<typeof WorkspacePill>>;
const externalApp: WorkspaceApp = {
id: "jetbrains-app",
slug: "jetbrains-gateway",
display_name: "JetBrains Gateway",
external: true,
url: "jetbrains-gateway://connect?$SESSION_TOKEN",
subdomain: false,
health: "disabled",
sharing_level: "owner",
hidden: false,
open_in: "slim-window",
statuses: [],
};
const cursorApp: WorkspaceApp = {
id: "cursor-app",
slug: "cursor",
display_name: "Cursor",
external: true,
url: "cursor://coder.coder-remote/open?$SESSION_TOKEN",
subdomain: false,
health: "disabled",
sharing_level: "owner",
hidden: false,
open_in: "slim-window",
statuses: [],
icon: "/icon/cursor.svg",
};
const hiddenApp: WorkspaceApp = {
id: "hidden-app",
slug: "hidden-internal",
display_name: "Hidden Internal Tool",
external: false,
url: "",
subdomain: false,
health: "disabled",
sharing_level: "owner",
hidden: true,
open_in: "slim-window",
statuses: [],
};
const agentWithApps = {
...MockWorkspaceAgent,
display_apps: ["vscode", "vscode_insiders", "web_terminal"] as const,
apps: [externalApp, cursorApp],
};
const agentWithBuiltinsOnly = {
...MockWorkspaceAgent,
display_apps: ["vscode", "web_terminal"] as const,
apps: [],
};
const agentWithNoApps = {
...MockWorkspaceAgent,
display_apps: [] as const,
apps: [],
};
const agentWithExternalOnly = {
...MockWorkspaceAgent,
display_apps: [] as const,
apps: [externalApp, cursorApp],
};
const agentWithHiddenApp = {
...MockWorkspaceAgent,
display_apps: ["vscode"] as const,
apps: [externalApp, hiddenApp],
};
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof WorkspacePill> = {
title: "pages/AgentsPage/WorkspacePill",
component: WorkspacePill,
// useAppLink calls useProxy(), so we need the proxy provider for
// stories that render AppMenuItem.
decorators: [withProxyProvider()],
parameters: {
layout: "centered",
queries: [{ key: ["me", "apiKey"], data: { key: "mock-api-key" } }],
},
};
export default meta;
type Story = StoryObj<typeof WorkspacePill>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
export const WithAllApps: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: agentWithApps,
sshCommand: "ssh coder.test-workspace",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("VS Code")).toBeInTheDocument();
expect(body.getByText("VS Code Insiders")).toBeInTheDocument();
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
expect(body.getByText("Cursor")).toBeInTheDocument();
expect(body.getByText("Terminal")).toBeInTheDocument();
expect(body.getByText("Copy SSH Command")).toBeInTheDocument();
expect(body.getByText("View Workspace")).toBeInTheDocument();
// Verify items are enabled on a running workspace.
const vscodeItem = body.getByText("VS Code").closest("[role=menuitem]");
expect(vscodeItem).not.toHaveAttribute("aria-disabled", "true");
// External apps should be enabled with API key mock.
const jetbrainsItem = body
.getByText("JetBrains Gateway")
.closest("[role=menuitem]");
expect(jetbrainsItem).not.toHaveAttribute("aria-disabled", "true");
const cursorItem = body.getByText("Cursor").closest("[role=menuitem]");
expect(cursorItem).not.toHaveAttribute("aria-disabled", "true");
});
},
};
export const WithBuiltinAppsOnly: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: agentWithBuiltinsOnly,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("VS Code")).toBeInTheDocument();
expect(body.getByText("Terminal")).toBeInTheDocument();
expect(body.getByText("View Workspace")).toBeInTheDocument();
// No external apps or VS Code Insiders.
expect(body.queryByText("VS Code Insiders")).not.toBeInTheDocument();
});
},
};
export const WithExternalAppsOnly: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: agentWithExternalOnly,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
expect(body.getByText("Cursor")).toBeInTheDocument();
expect(body.getByText("View Workspace")).toBeInTheDocument();
// No built-in apps.
expect(body.queryByText("VS Code")).not.toBeInTheDocument();
expect(body.queryByText("Terminal")).not.toBeInTheDocument();
});
},
};
export const NoApps: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: agentWithNoApps,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("View Workspace")).toBeInTheDocument();
});
},
};
export const WithHiddenApp: Story = {
args: {
...defaultProps,
workspace: MockWorkspace,
agent: agentWithHiddenApp,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText("Test-Workspace");
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
// Visible apps should appear.
expect(body.getByText("VS Code")).toBeInTheDocument();
expect(body.getByText("JetBrains Gateway")).toBeInTheDocument();
// Hidden app should NOT appear.
expect(body.queryByText("Hidden Internal Tool")).not.toBeInTheDocument();
});
},
};
export const WithStoppedWorkspace: Story = {
args: {
...defaultProps,
workspace: MockStoppedWorkspace,
agent: agentWithApps,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const pill = canvas.getByText(MockStoppedWorkspace.name);
await userEvent.click(pill);
await waitFor(() => {
const body = within(document.body);
// VS Code items should be present but disabled.
const vscodeItem = body.getByText("VS Code").closest("[role=menuitem]");
expect(vscodeItem).toHaveAttribute("aria-disabled", "true");
const vscodeInsidersItem = body
.getByText("VS Code Insiders")
.closest("[role=menuitem]");
expect(vscodeInsidersItem).toHaveAttribute("aria-disabled", "true");
// Terminal item should be disabled.
const terminalItem = body
.getByText("Terminal")
.closest("[role=menuitem]");
expect(terminalItem).toHaveAttribute("aria-disabled", "true");
// External app items should be disabled.
const jetbrainsItem = body
.getByText("JetBrains Gateway")
.closest("[role=menuitem]");
expect(jetbrainsItem).toHaveAttribute("aria-disabled", "true");
expect(jetbrainsItem).not.toHaveAttribute("href");
const cursorItem = body.getByText("Cursor").closest("[role=menuitem]");
expect(cursorItem).toHaveAttribute("aria-disabled", "true");
expect(cursorItem).not.toHaveAttribute("href");
// View Workspace link should still be accessible.
expect(body.getByText("View Workspace")).toBeInTheDocument();
});
},
};
@@ -0,0 +1,302 @@
import {
ChevronDownIcon,
CopyIcon,
ExternalLinkIcon,
LayoutGridIcon,
MonitorIcon,
SquareTerminalIcon,
} from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { useMutation } from "react-query";
import { Link } from "react-router";
import { toast } from "sonner";
import { API } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "#/api/typesGenerated";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useClipboard } from "#/hooks/useClipboard";
import {
getTerminalHref,
getVSCodeHref,
isExternalApp,
needsSessionToken,
openAppInNewWindow,
} from "#/modules/apps/apps";
import { useAppLink } from "#/modules/apps/useAppLink";
import { cn } from "#/utils/cn";
import { getWorkspaceStatus, StatusIcon } from "./StatusIcon";
interface WorkspacePillProps {
workspace: Workspace;
agent: WorkspaceAgent;
chatId: string;
sshCommand?: string;
folder?: string;
}
export const WorkspacePill: FC<WorkspacePillProps> = ({
workspace,
agent,
chatId,
sshCommand,
folder,
}) => {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const isRunning = workspace.latest_build.status === "running";
const route = `/@${workspace.owner_name}/${workspace.name}`;
const { effectiveType, statusLabel } = getWorkspaceStatus(workspace, agent);
const { mutate: generateKey, isPending: isGeneratingKey } = useMutation({
mutationFn: () => API.getApiKey(),
});
const builtinApps = new Set(agent.display_apps);
const hasVSCode = builtinApps.has("vscode");
const hasVSCodeInsiders = builtinApps.has("vscode_insiders");
const hasTerminal = builtinApps.has("web_terminal");
const userApps = agent.apps.filter((app) => !app.hidden);
const hasItemsAboveSeparator =
hasVSCode || hasVSCodeInsiders || userApps.length > 0 || hasTerminal;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip
open={tooltipOpen}
onOpenChange={(v) => setTooltipOpen(v && !open)}
>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`${workspace.name} workspace menu`}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary",
"cursor-pointer border-0 transition-colors hover:bg-surface-tertiary hover:text-content-primary",
)}
>
<StatusIcon type={effectiveType} /> {workspace.name}
{/* The menu opens upward (side="top"), so the chevron
points away from the menu when closed (default) and
toward it when open (rotate-180). */}
<ChevronDownIcon
className={cn(
"size-3 opacity-60 transition-transform",
open && "rotate-180",
)}
/>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{statusLabel}</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="top"
align="start"
className="w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
>
{hasVSCode && (
<VSCodeMenuItem
variant="vscode"
label="VS Code"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{hasVSCodeInsiders && (
<VSCodeMenuItem
variant="vscode-insiders"
label="VS Code Insiders"
workspace={workspace}
agent={agent}
chatId={chatId}
folder={folder}
isRunning={isRunning}
generateKey={generateKey}
isGeneratingKey={isGeneratingKey}
/>
)}
{userApps.map((app) => (
<AppMenuItem
key={app.id}
app={app}
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
))}
{hasTerminal && (
<TerminalMenuItem
workspace={workspace}
agent={agent}
isRunning={isRunning}
/>
)}
{hasItemsAboveSeparator && <DropdownMenuSeparator className="my-1" />}
{sshCommand && <CopySSHMenuItem sshCommand={sshCommand} />}
<DropdownMenuItem asChild>
<Link to={route} target="_blank" rel="noreferrer">
<MonitorIcon className="size-3.5" />
View Workspace
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const VSCodeMenuItem: FC<{
variant: "vscode" | "vscode-insiders";
label: string;
workspace: Workspace;
agent: WorkspaceAgent;
chatId: string;
folder?: string;
isRunning: boolean;
generateKey: (
variables: undefined,
options: {
onSuccess: (data: { key: string }) => void;
onError: (error: unknown) => void;
},
) => void;
isGeneratingKey: boolean;
}> = ({
variant,
label,
workspace,
agent,
chatId,
folder,
isRunning,
generateKey,
isGeneratingKey,
}) => {
const handleClick = () => {
generateKey(undefined, {
onSuccess: ({ key }) => {
location.href = getVSCodeHref(variant, {
owner: workspace.owner_name,
workspace: workspace.name,
token: key,
agent: agent.name,
folder: folder ?? agent.expanded_directory,
chatId,
});
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, `Failed to open ${label}.`));
},
});
};
return (
<DropdownMenuItem
onSelect={handleClick}
disabled={isGeneratingKey || !isRunning}
>
<ExternalLinkIcon className="size-3.5" />
{label}
</DropdownMenuItem>
);
};
const AppMenuItem: FC<{
app: WorkspaceApp;
workspace: Workspace;
agent: WorkspaceAgent;
isRunning: boolean;
}> = ({ app, workspace, agent, isRunning }) => {
const link = useAppLink(app, { workspace, agent });
const canClick =
!isExternalApp(app) || !needsSessionToken(app) || link.hasToken;
return (
<DropdownMenuItem asChild disabled={!canClick || !isRunning}>
<a
href={canClick && isRunning ? link.href : undefined}
onClick={link.onClick}
target="_blank"
rel="noreferrer"
>
{app.icon ? (
<ExternalImage
src={app.icon}
alt=""
className="size-3.5 rounded-sm"
/>
) : (
<LayoutGridIcon className="size-3.5" />
)}
{link.label}
</a>
</DropdownMenuItem>
);
};
const TerminalMenuItem: FC<{
workspace: Workspace;
agent: WorkspaceAgent;
isRunning: boolean;
}> = ({ workspace, agent, isRunning }) => {
const href = getTerminalHref({
username: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
});
return (
<DropdownMenuItem
onSelect={() => {
openAppInNewWindow(href);
}}
disabled={!isRunning}
>
<SquareTerminalIcon className="size-3.5" />
Terminal
</DropdownMenuItem>
);
};
const CopySSHMenuItem: FC<{
sshCommand: string;
}> = ({ sshCommand }) => {
const { copyToClipboard } = useClipboard();
return (
<DropdownMenuItem
onSelect={() => {
void copyToClipboard(sshCommand);
}}
>
<CopyIcon className="size-3.5" />
Copy SSH Command
</DropdownMenuItem>
);
};