diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index af610a67f1..9ce69ff735 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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(); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index f7945d9778..071b523046 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 7ccf5af09d..7b589869e8 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -137,12 +137,7 @@ const StoryAgentChatPageView: FC = ({ 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: () => ( ), diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 84e0837a62..e1ace66aba 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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 = ({ prNumber, diffStatusData, gitWatcher, - canOpenEditors, - canOpenWorkspace, sshCommand, - handleOpenInEditor, - handleViewWorkspace, - handleOpenTerminal, handleCommit, handleInterrupt, handleDeleteQueuedMessage, @@ -275,48 +255,29 @@ export const AgentChatPageView: FC = ({ // 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 = { - success: , - active: , - inactive: , - error: , - danger: , - warning: , - }; + const statusIcon = ; return { name: workspace.name, route: workspaceRoute, - statusIcon: statusIconMap[effectiveType], + statusIcon, statusLabel, }; })(); @@ -357,14 +318,6 @@ export const AgentChatPageView: FC = ({ 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 = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} lastInjectedContext={lastInjectedContext} + workspace={workspace} + workspaceAgent={workspaceAgent} + chatId={agentId} + sshCommand={sshCommand} attachedWorkspace={attachedWorkspace} + folder={preferredFolder} /> @@ -568,14 +526,6 @@ export const AgentChatPageLoadingView: FC = ({ showSidebarPanel: false, onToggleSidebar: () => {}, }} - workspace={{ - canOpenEditors: false, - canOpenWorkspace: false, - onOpenInEditor: () => {}, - onViewWorkspace: () => {}, - onOpenTerminal: () => {}, - sshCommand: undefined, - }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} onRegenerateTitle={() => {}} @@ -646,14 +596,6 @@ export const AgentChatPageNotFoundView: FC = ({ showSidebarPanel: false, onToggleSidebar: () => {}, }} - workspace={{ - canOpenEditors: false, - canOpenWorkspace: false, - onOpenInEditor: () => {}, - onViewWorkspace: () => {}, - onOpenTerminal: () => {}, - sshCommand: undefined, - }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} onRegenerateTitle={() => {}} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index c9dbffbd04..1a90b9e966 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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 = ({ selectedMCPServerIds, onMCPSelectionChange, onMCPAuthComplete, + workspace, + workspaceAgent, + chatId, + sshCommand, attachedWorkspace, + folder, }) => { const [chatFullWidth] = useChatFullWidth(); const internalRef = useRef(null); @@ -395,7 +406,10 @@ export const AgentChatInput: FC = ({ // 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 = ({ * 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 && ( + + )}
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 = ({ @@ -222,7 +227,12 @@ export const ChatPageInput: FC = ({ 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 = ({ selectedMCPServerIds={selectedMCPServerIds} onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} + workspace={workspace} + workspaceAgent={workspaceAgent} + chatId={chatId} + sshCommand={sshCommand} attachedWorkspace={attachedWorkspace} + folder={folder} /> ); diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 244ab9d27a..a950cf8104 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -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(), diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 297e78796e..35eee3d5b5 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -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 = ({ chatTitle, parentChat, panel, - workspace, onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, @@ -206,58 +190,8 @@ export const ChatTopBar: FC = ({ align="end" className="[&_[role=menuitem]]:text-[13px]" > - { - workspace.onOpenInEditor("cursor"); - }} - > - - Open in Cursor - - { - workspace.onOpenInEditor("vscode"); - }} - > - - Open in VS Code - - - - Open Terminal - - { - 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"); - } - }} - > - - Copy SSH Command - - - - - View Workspace - {!isArchived && ( <> - {onRegenerateTitle && ( <> +> = { + 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 ; +}; + +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 }; +} diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx new file mode 100644 index 0000000000..2c65beb42d --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx @@ -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>; + +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 = { + 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; + +// --------------------------------------------------------------------------- +// 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(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx new file mode 100644 index 0000000000..2ad1798033 --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -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 = ({ + 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 ( + + setTooltipOpen(v && !open)} + > + + + + + + {statusLabel} + + + {hasVSCode && ( + + )} + {hasVSCodeInsiders && ( + + )} + {userApps.map((app) => ( + + ))} + {hasTerminal && ( + + )} + {hasItemsAboveSeparator && } + {sshCommand && } + + + + View Workspace + + + + + ); +}; + +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 ( + + + {label} + + ); +}; + +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 ( + + + {app.icon ? ( + + ) : ( + + )} + {link.label} + + + ); +}; + +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 ( + { + openAppInNewWindow(href); + }} + disabled={!isRunning} + > + + Terminal + + ); +}; + +const CopySSHMenuItem: FC<{ + sshCommand: string; +}> = ({ sshCommand }) => { + const { copyToClipboard } = useClipboard(); + + return ( + { + void copyToClipboard(sshCommand); + }} + > + + Copy SSH Command + + ); +};