Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypesGen.Response>(
`/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]),
Expand Down
11 changes: 11 additions & 0 deletions site/src/modules/resources/AgentDevcontainerCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
48 changes: 33 additions & 15 deletions site/src/modules/resources/AgentDevcontainerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import Skeleton from "@mui/material/Skeleton";
import { API } from "api/api";
import type {
Template,
Workspace,
WorkspaceAgent,
WorkspaceAgentDevcontainer,
WorkspaceAgentListContainersResponse,
} from "api/typesGenerated";

import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import { Spinner } from "components/Spinner/Spinner";
Expand All @@ -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";
Expand Down Expand Up @@ -80,17 +81,10 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({

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({
Expand Down Expand Up @@ -168,6 +162,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({

const showDevcontainerControls = subAgent && devcontainer.container;
const showSubAgentApps =
devcontainer.status !== "deleting" &&
devcontainer.status !== "starting" &&
subAgent?.status === "connected" &&
hasAppsToDisplay;
Expand Down Expand Up @@ -250,11 +245,27 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={devcontainer.status === "starting"}
disabled={
devcontainer.status === "starting" ||
devcontainer.status === "stopping" ||
devcontainer.status === "deleting"
}
>
<Spinner loading={devcontainer.status === "starting"} />
<Spinner
loading={
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";
})()}
</Button>

{showDevcontainerControls && displayApps.includes("ssh_helper") && (
Expand All @@ -274,6 +285,13 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
template={template}
/>
)}

{showDevcontainerControls && (
<AgentDevcontainerMoreActions
devcontainer={devcontainer}
parentAgent={parentAgent}
/>
)}
</div>
</header>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof AgentDevcontainerMoreActions> = {
title: "modules/resources/AgentDevcontainerMoreActions",
component: AgentDevcontainerMoreActions,
args: {
parentAgent: MockWorkspaceAgent,
devcontainer: MockWorkspaceAgentDevcontainer,
},
};

export default meta;
type Story = StoryObj<typeof AgentDevcontainerMoreActions>;

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,
});
});
},
};
140 changes: 140 additions & 0 deletions site/src/modules/resources/AgentDevcontainerMoreActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-controls={menuContentId}>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Dev Container actions</span>
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent id={menuContentId} align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => {
setIsConfirmingDelete(true);
}}
>
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>

<DevcontainerDeleteDialog
isOpen={isConfirmingDelete}
onCancel={() => setIsConfirmingDelete(false)}
onConfirm={() => {
deleteDevContainerMutation.mutate();
setIsConfirmingDelete(false);
}}
/>
</DropdownMenu>
);
};

type DevcontainerDeleteDialogProps = {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
};

const DevcontainerDeleteDialog: FC<DevcontainerDeleteDialogProps> = ({
isOpen,
onCancel,
onConfirm,
}) => {
return (
<ConfirmDialog
type="delete"
open={isOpen}
title="Delete Dev Container"
onConfirm={onConfirm}
onClose={onCancel}
description={
<p>
Are you sure you want to delete this Dev Container? Any unsaved work
will be lost.
</p>
}
/>
);
};
Loading