Skip to content

Commit de39f09

Browse files
feat(site): support deleting dev containers
1 parent 39e6bc5 commit de39f09

File tree

4 files changed

+212
-14
lines changed

4 files changed

+212
-14
lines changed

site/src/api/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,6 +2631,31 @@ class ApiMethods {
26312631
}
26322632
};
26332633

2634+
deleteDevContainer = async ({
2635+
parentAgentId,
2636+
devcontainerId,
2637+
}: {
2638+
parentAgentId: string;
2639+
devcontainerId: string;
2640+
}) => {
2641+
await this.axios.delete(
2642+
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}`,
2643+
);
2644+
};
2645+
2646+
recreateDevContainer = async ({
2647+
parentAgentId,
2648+
devcontainerId,
2649+
}: {
2650+
parentAgentId: string;
2651+
devcontainerId: string;
2652+
}) => {
2653+
const response = await this.axios.post<TypesGen.Response>(
2654+
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}/recreate`,
2655+
);
2656+
return response.data;
2657+
};
2658+
26342659
getAgentContainers = async (agentId: string, labels?: string[]) => {
26352660
const params = new URLSearchParams(
26362661
labels?.map((label) => ["label", label]),

site/src/modules/resources/AgentDevcontainerCard.stories.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ export const Recreating: Story = {
9191
},
9292
};
9393

94+
export const Stopping: Story = {
95+
args: {
96+
devcontainer: {
97+
...MockWorkspaceAgentDevcontainer,
98+
dirty: true,
99+
status: "stopping",
100+
},
101+
subAgents: [],
102+
},
103+
};
104+
94105
export const NoContainerOrSubAgent: Story = {
95106
args: {
96107
devcontainer: {

site/src/modules/resources/AgentDevcontainerCard.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import { AgentSSHButton } from "./SSHButton/SSHButton";
3434
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
3535
import { TerminalLink } from "./TerminalLink/TerminalLink";
3636
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton";
37+
import { API } from "api/api";
38+
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
3739

3840
type AgentDevcontainerCardProps = {
3941
parentAgent: WorkspaceAgent;
@@ -80,17 +82,10 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
8082

8183
const rebuildDevcontainerMutation = useMutation({
8284
mutationFn: async () => {
83-
const response = await fetch(
84-
`/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/${devcontainer.id}/recreate`,
85-
{ method: "POST" },
86-
);
87-
if (!response.ok) {
88-
const errorData = await response.json().catch(() => ({}));
89-
throw new Error(
90-
errorData.message || `Failed to rebuild: ${response.statusText}`,
91-
);
92-
}
93-
return response;
85+
await API.recreateDevContainer({
86+
parentAgentId: parentAgent.id,
87+
devcontainerId: devcontainer.id,
88+
});
9489
},
9590
onMutate: async () => {
9691
await queryClient.cancelQueries({
@@ -168,6 +163,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
168163

169164
const showDevcontainerControls = subAgent && devcontainer.container;
170165
const showSubAgentApps =
166+
devcontainer.status !== "stopping" &&
171167
devcontainer.status !== "starting" &&
172168
subAgent?.status === "connected" &&
173169
hasAppsToDisplay;
@@ -250,11 +246,23 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
250246
variant="outline"
251247
size="sm"
252248
onClick={handleRebuildDevcontainer}
253-
disabled={devcontainer.status === "starting"}
249+
disabled={
250+
devcontainer.status === "starting" ||
251+
devcontainer.status === "stopping"
252+
}
254253
>
255-
<Spinner loading={devcontainer.status === "starting"} />
254+
<Spinner
255+
loading={
256+
devcontainer.status === "starting" ||
257+
devcontainer.status === "stopping"
258+
}
259+
/>
256260

257-
{devcontainer.container === undefined ? "Start" : "Rebuild"}
261+
{devcontainer.status === "stopping"
262+
? "Stop"
263+
: devcontainer.container === undefined
264+
? "Start"
265+
: "Rebuild"}
258266
</Button>
259267

260268
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
@@ -274,6 +282,13 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
274282
template={template}
275283
/>
276284
)}
285+
286+
{showDevcontainerControls && (
287+
<AgentDevcontainerMoreActions
288+
devcontainer={devcontainer}
289+
parentAgent={parentAgent}
290+
/>
291+
)}
277292
</div>
278293
</header>
279294

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { API } from "api/api";
2+
import type {
3+
WorkspaceAgent,
4+
WorkspaceAgentDevcontainer,
5+
WorkspaceAgentListContainersResponse,
6+
} from "api/typesGenerated";
7+
import { Button } from "components/Button/Button";
8+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
} from "components/DropdownMenu/DropdownMenu";
15+
import { EllipsisVertical } from "lucide-react";
16+
import { type FC, useId, useState } from "react";
17+
import { useMutation, useQueryClient } from "react-query";
18+
19+
type AgentDevcontainerMoreActionsProps = {
20+
parentAgent: WorkspaceAgent;
21+
devcontainer: WorkspaceAgentDevcontainer;
22+
};
23+
24+
export const AgentDevcontainerMoreActions: FC<
25+
AgentDevcontainerMoreActionsProps
26+
> = ({ parentAgent, devcontainer }) => {
27+
const queryClient = useQueryClient();
28+
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
29+
const [open, setOpen] = useState(false);
30+
const menuContentId = useId();
31+
32+
const deleteDevContainerMutation = useMutation({
33+
mutationFn: async () => {
34+
await API.deleteDevContainer({
35+
parentAgentId: parentAgent.id,
36+
devcontainerId: devcontainer.id,
37+
});
38+
},
39+
onMutate: async () => {
40+
await queryClient.cancelQueries({
41+
queryKey: ["agents", parentAgent.id, "containers"],
42+
});
43+
44+
// Snapshot the previous data for rollback in case of error.
45+
const previousData = queryClient.getQueryData([
46+
"agents",
47+
parentAgent.id,
48+
"containers",
49+
]);
50+
51+
// Optimistically update the devcontainer status to
52+
// "stopping" and zero the agent and container to mimic what
53+
// the API does.
54+
queryClient.setQueryData(
55+
["agents", parentAgent.id, "containers"],
56+
(oldData?: WorkspaceAgentListContainersResponse) => {
57+
if (!oldData?.devcontainers) return oldData;
58+
return {
59+
...oldData,
60+
devcontainers: oldData.devcontainers.map((dc) => {
61+
if (dc.id === devcontainer.id) {
62+
return {
63+
...dc,
64+
status: "stopping",
65+
container: undefined,
66+
};
67+
}
68+
return dc;
69+
}),
70+
};
71+
},
72+
);
73+
74+
return { previousData };
75+
},
76+
onError: (_, __, context) => {
77+
// If the mutation fails, use the context returned from
78+
// onMutate to roll back. The backend will set the error
79+
// field on the devcontainer which will be displayed inline.
80+
if (context?.previousData) {
81+
queryClient.setQueryData(
82+
["agents", parentAgent.id, "containers"],
83+
context.previousData,
84+
);
85+
}
86+
},
87+
});
88+
89+
return (
90+
<DropdownMenu open={open} onOpenChange={setOpen}>
91+
<DropdownMenuTrigger asChild>
92+
<Button size="icon-lg" variant="subtle" aria-controls={menuContentId}>
93+
<EllipsisVertical aria-hidden="true" />
94+
<span className="sr-only">Dev Container actions</span>
95+
</Button>
96+
</DropdownMenuTrigger>
97+
98+
<DropdownMenuContent id={menuContentId} align="end">
99+
<DropdownMenuItem
100+
className="text-content-destructive focus:text-content-destructive"
101+
onClick={() => {
102+
setIsConfirmingDelete(true);
103+
}}
104+
>
105+
Delete&hellip;
106+
</DropdownMenuItem>
107+
</DropdownMenuContent>
108+
109+
<DevcontainerDeleteDialog
110+
isOpen={isConfirmingDelete}
111+
onCancel={() => setIsConfirmingDelete(false)}
112+
onConfirm={() => {
113+
deleteDevContainerMutation.mutate();
114+
setIsConfirmingDelete(false);
115+
}}
116+
/>
117+
</DropdownMenu>
118+
);
119+
};
120+
121+
type DevcontainerDeleteDialogProps = {
122+
isOpen: boolean;
123+
onCancel: () => void;
124+
onConfirm: () => void;
125+
};
126+
127+
const DevcontainerDeleteDialog: FC<DevcontainerDeleteDialogProps> = ({
128+
isOpen,
129+
onCancel,
130+
onConfirm,
131+
}) => {
132+
return (
133+
<ConfirmDialog
134+
type="delete"
135+
open={isOpen}
136+
title="Delete Dev Container"
137+
onConfirm={onConfirm}
138+
onClose={onCancel}
139+
description={
140+
<p>
141+
Are you sure you want to delete this Dev Container? Any unsaved work
142+
will be lost.
143+
</p>
144+
}
145+
/>
146+
);
147+
};

0 commit comments

Comments
 (0)