From 75796f25844d89132e2cb821eca240951b61d4c3 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 26 Nov 2025 12:50:32 +0000 Subject: [PATCH] feat(site): support deleting dev containers --- site/src/api/api.ts | 25 ++++ .../AgentDevcontainerCard.stories.tsx | 11 ++ .../resources/AgentDevcontainerCard.tsx | 48 ++++-- .../AgentDevcontainerMoreActions.stories.tsx | 80 ++++++++++ .../AgentDevcontainerMoreActions.tsx | 140 ++++++++++++++++++ 5 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx create mode 100644 site/src/modules/resources/AgentDevcontainerMoreActions.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 530bb7c6c73a7..3b97808e6a0ea 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2631,6 +2631,31 @@ class ApiMethods { } }; + deleteDevContainer = async ({ + parentAgentId, + devcontainerId, + }: { + parentAgentId: string; + devcontainerId: string; + }) => { + await this.axios.delete( + `/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}`, + ); + }; + + recreateDevContainer = async ({ + parentAgentId, + devcontainerId, + }: { + parentAgentId: string; + devcontainerId: string; + }) => { + const response = await this.axios.post( + `/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}/recreate`, + ); + return response.data; + }; + getAgentContainers = async (agentId: string, labels?: string[]) => { const params = new URLSearchParams( labels?.map((label) => ["label", label]), diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index a06b4ba3ab8b4..6ca5a0fe18ed2 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -91,6 +91,17 @@ export const Recreating: Story = { }, }; +export const Stopping: Story = { + args: { + devcontainer: { + ...MockWorkspaceAgentDevcontainer, + dirty: true, + status: "stopping", + }, + subAgents: [], + }, +}; + export const NoContainerOrSubAgent: Story = { args: { devcontainer: { diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index b17cbafec1a9c..430f7663a2386 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -1,4 +1,5 @@ import Skeleton from "@mui/material/Skeleton"; +import { API } from "api/api"; import type { Template, Workspace, @@ -6,7 +7,6 @@ import type { WorkspaceAgentDevcontainer, WorkspaceAgentListContainersResponse, } from "api/typesGenerated"; - import { Button } from "components/Button/Button"; import { displayError } from "components/GlobalSnackbar/utils"; import { Spinner } from "components/Spinner/Spinner"; @@ -27,6 +27,7 @@ import { cn } from "utils/cn"; import { portForwardURL } from "utils/portForward"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; import { AgentButton } from "./AgentButton"; +import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions"; import { AgentLatency } from "./AgentLatency"; import { DevcontainerStatus } from "./AgentStatus"; import { PortForwardButton } from "./PortForwardButton"; @@ -80,17 +81,10 @@ export const AgentDevcontainerCard: FC = ({ const rebuildDevcontainerMutation = useMutation({ mutationFn: async () => { - const response = await fetch( - `/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/${devcontainer.id}/recreate`, - { method: "POST" }, - ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.message || `Failed to rebuild: ${response.statusText}`, - ); - } - return response; + await API.recreateDevContainer({ + parentAgentId: parentAgent.id, + devcontainerId: devcontainer.id, + }); }, onMutate: async () => { await queryClient.cancelQueries({ @@ -168,6 +162,7 @@ export const AgentDevcontainerCard: FC = ({ const showDevcontainerControls = subAgent && devcontainer.container; const showSubAgentApps = + devcontainer.status !== "deleting" && devcontainer.status !== "starting" && subAgent?.status === "connected" && hasAppsToDisplay; @@ -250,11 +245,27 @@ export const AgentDevcontainerCard: FC = ({ variant="outline" size="sm" onClick={handleRebuildDevcontainer} - disabled={devcontainer.status === "starting"} + disabled={ + devcontainer.status === "starting" || + devcontainer.status === "stopping" || + devcontainer.status === "deleting" + } > - + - {devcontainer.container === undefined ? "Start" : "Rebuild"} + {(() => { + if (devcontainer.status === "deleting") return "Delete"; + if (devcontainer.status === "stopping") return "Stop"; + if (devcontainer.container === undefined) return "Start"; + + return "Rebuild"; + })()} {showDevcontainerControls && displayApps.includes("ssh_helper") && ( @@ -274,6 +285,13 @@ export const AgentDevcontainerCard: FC = ({ template={template} /> )} + + {showDevcontainerControls && ( + + )} diff --git a/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx b/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx new file mode 100644 index 0000000000000..666c2635152c9 --- /dev/null +++ b/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx @@ -0,0 +1,80 @@ +import { + MockWorkspaceAgent, + MockWorkspaceAgentDevcontainer, +} from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions"; + +const meta: Meta = { + title: "modules/resources/AgentDevcontainerMoreActions", + component: AgentDevcontainerMoreActions, + args: { + parentAgent: MockWorkspaceAgent, + devcontainer: MockWorkspaceAgentDevcontainer, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const MenuOpen: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + await user.click( + canvas.getByRole("button", { name: "Dev Container actions" }), + ); + + const body = canvasElement.ownerDocument.body; + await within(body).findByText("Delete…"); + }, +}; + +export const ConfirmDialogOpen: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + await user.click( + canvas.getByRole("button", { name: "Dev Container actions" }), + ); + + const body = canvasElement.ownerDocument.body; + await user.click(await within(body).findByText("Delete…")); + + await within(body).findByText("Delete Dev Container"); + }, +}; + +export const ConfirmDeleteCallsAPI: Story = { + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + const deleteSpy = spyOn(API, "deleteDevContainer").mockResolvedValue( + undefined as never, + ); + + await user.click( + canvas.getByRole("button", { name: "Dev Container actions" }), + ); + + const body = canvasElement.ownerDocument.body; + await user.click(await within(body).findByText("Delete…")); + + await user.click(within(body).getByTestId("confirm-button")); + + await waitFor(() => { + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).toHaveBeenCalledWith({ + parentAgentId: args.parentAgent.id, + devcontainerId: args.devcontainer.id, + }); + }); + }, +}; diff --git a/site/src/modules/resources/AgentDevcontainerMoreActions.tsx b/site/src/modules/resources/AgentDevcontainerMoreActions.tsx new file mode 100644 index 0000000000000..737b64c748a44 --- /dev/null +++ b/site/src/modules/resources/AgentDevcontainerMoreActions.tsx @@ -0,0 +1,140 @@ +import { API } from "api/api"; +import type { + WorkspaceAgent, + WorkspaceAgentDevcontainer, + WorkspaceAgentListContainersResponse, +} from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { EllipsisVertical } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; + +type AgentDevcontainerMoreActionsProps = { + parentAgent: WorkspaceAgent; + devcontainer: WorkspaceAgentDevcontainer; +}; + +export const AgentDevcontainerMoreActions: FC< + AgentDevcontainerMoreActionsProps +> = ({ parentAgent, devcontainer }) => { + const queryClient = useQueryClient(); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const [open, setOpen] = useState(false); + const menuContentId = useId(); + + const deleteDevContainerMutation = useMutation({ + mutationFn: async () => { + await API.deleteDevContainer({ + parentAgentId: parentAgent.id, + devcontainerId: devcontainer.id, + }); + }, + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: ["agents", parentAgent.id, "containers"], + }); + + const previousData = queryClient.getQueryData([ + "agents", + parentAgent.id, + "containers", + ]); + + queryClient.setQueryData( + ["agents", parentAgent.id, "containers"], + (oldData?: WorkspaceAgentListContainersResponse) => { + if (!oldData?.devcontainers) return oldData; + return { + ...oldData, + devcontainers: oldData.devcontainers.map((dc) => { + if (dc.id === devcontainer.id) { + return { + ...dc, + status: "stopping", + container: undefined, + }; + } + return dc; + }), + }; + }, + ); + + return { previousData }; + }, + onError: (_, __, context) => { + if (context?.previousData) { + queryClient.setQueryData( + ["agents", parentAgent.id, "containers"], + context.previousData, + ); + } + }, + }); + + return ( + + + + + + + { + setIsConfirmingDelete(true); + }} + > + Delete… + + + + setIsConfirmingDelete(false)} + onConfirm={() => { + deleteDevContainerMutation.mutate(); + setIsConfirmingDelete(false); + }} + /> + + ); +}; + +type DevcontainerDeleteDialogProps = { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +const DevcontainerDeleteDialog: FC = ({ + isOpen, + onCancel, + onConfirm, +}) => { + return ( + + Are you sure you want to delete this Dev Container? Any unsaved work + will be lost. +

+ } + /> + ); +};