feat(site): confirm before batch stopping workspaces (#27631)

> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

## What

Bulk stopping workspaces from the workspaces table currently fires
immediately with no confirmation, whereas the single-row **Stop** action
and the bulk **Delete** / **Update** actions all show a dialog first.
This adds a confirmation dialog to the bulk **Stop** action so it
matches the rest.

## How

- Added `BatchStopConfirmation`, a small `ConfirmDialog` wrapper
mirroring the wording of the single-workspace stop confirmation but
pluralized for the selected count.
- Wired it into `WorkspacesPage`: `onBatchStopTransition` now opens the
dialog (`setActiveBatchAction("stop")`) instead of calling
`batchActions.stop(...)` directly, and the actual stop runs on confirm.
Added `"stop"` to the `BatchAction` union.

No change to the underlying `batchActions.stop` behavior (still only
stops `running` workspaces).

<details>
<summary>Reviewer notes</summary>

Before: `onBatchStopTransition={() =>
batchActions.stop(checkedWorkspaces)}` — no confirmation.

After: opens `BatchStopConfirmation`; confirm calls
`batchActions.stop(checkedWorkspaces)` then clears the active action,
consistent with how `BatchDeleteConfirmation` and `BatchUpdateModalForm`
are handled.

</details>

---
_Opened as a draft. Disclosure: this PR was generated by Coder Agents on
behalf of @jakehwll._
This commit is contained in:
Jake Howell
2026-07-29 04:26:09 +00:00
committed by GitHub
parent fbac602456
commit d072aa7bd0
3 changed files with 63 additions and 2 deletions
@@ -0,0 +1,36 @@
import type { FC } from "react";
import type { Workspace } from "#/api/typesGenerated";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
type BatchStopConfirmationProps = {
workspacesToStop: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export const BatchStopConfirmation: FC<BatchStopConfirmationProps> = ({
workspacesToStop,
open,
onClose,
onConfirm,
isLoading,
}) => {
const workspaceCount = `${workspacesToStop.length} ${
workspacesToStop.length === 1 ? "workspace" : "workspaces"
}`;
return (
<ConfirmDialog
type="delete"
open={open}
onClose={onClose}
title={`Stop ${workspaceCount}`}
confirmLoading={isLoading}
confirmText="Stop"
onConfirm={onConfirm}
description={`Are you sure you want to stop ${workspaceCount}? This will terminate all running processes and disconnect any active sessions.`}
/>
);
};
@@ -433,6 +433,9 @@ export const StopsOnlySelectedWorkspaces: Story = {
await openBulkActions(canvas, user);
await user.click(await body.findByRole("menuitem", { name: /stop/i }));
const dialog = await body.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Stop" }));
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(2));
expect(API.stopWorkspace).toHaveBeenCalledWith("1");
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
@@ -532,6 +535,9 @@ export const StopIgnoresAlreadyStoppedWorkspaces: Story = {
expect(stopItem).not.toHaveAttribute("data-disabled");
await user.click(stopItem);
const dialog = await body.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Stop" }));
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(1));
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
},
@@ -23,6 +23,7 @@ import { useOrganizationsFilterMenu } from "#/modules/tableFiltering/options";
import { ACTIVE_BUILD_STATUSES } from "#/modules/workspaces/status";
import { pageTitle } from "#/utils/page";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
import { BatchStopConfirmation } from "./BatchStopConfirmation";
import { BatchUpdateModalForm } from "./BatchUpdateModalForm";
import { useBatchActions } from "./batchActions";
import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus";
@@ -52,7 +53,7 @@ function useSafeSearchParams() {
>;
}
type BatchAction = "delete" | "update";
type BatchAction = "delete" | "stop" | "update";
const WorkspacesPage: FC = () => {
const queryClient = useQueryClient();
@@ -163,6 +164,13 @@ const WorkspacesPage: FC = () => {
const checkedWorkspaces =
data?.workspaces.filter((w) => checkedWorkspaceIds.has(w.id)) ?? [];
// Bulk stop only affects running workspaces, so the confirmation dialog and
// the mutation should both operate on that subset to avoid over-reporting
// how many workspaces will actually be stopped.
const workspacesToStop = checkedWorkspaces.filter(
(w) => w.latest_build.status === "running",
);
return (
<>
<title>{pageTitle("Workspaces")}</title>
@@ -197,7 +205,7 @@ const WorkspacesPage: FC = () => {
isRunningBatchAction={batchActions.isProcessing}
onBatchDeleteTransition={() => setActiveBatchAction("delete")}
onBatchStartTransition={() => batchActions.start(checkedWorkspaces)}
onBatchStopTransition={() => batchActions.stop(checkedWorkspaces)}
onBatchStopTransition={() => setActiveBatchAction("stop")}
onBatchUpdateTransition={() => {
// Just because batch-updating can be really dangerous
// action for running workspaces, we're going to invalidate
@@ -241,6 +249,17 @@ const WorkspacesPage: FC = () => {
}}
/>
<BatchStopConfirmation
isLoading={batchActions.isProcessing}
workspacesToStop={workspacesToStop}
open={activeBatchAction === "stop"}
onClose={() => setActiveBatchAction(undefined)}
onConfirm={async () => {
await batchActions.stop(workspacesToStop);
setActiveBatchAction(undefined);
}}
/>
<BatchUpdateModalForm
open={activeBatchAction === "update"}
workspacesToUpdate={checkedWorkspaces}