diff --git a/.server-changes/bulk-action-abort-from-list.md b/.server-changes/bulk-action-abort-from-list.md
new file mode 100644
index 0000000000..10e55a7ee5
--- /dev/null
+++ b/.server-changes/bulk-action-abort-from-list.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+Abort an in-progress bulk action directly from the bulk actions list, with a confirmation step before it runs.
diff --git a/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx
new file mode 100644
index 0000000000..e895961140
--- /dev/null
+++ b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx
@@ -0,0 +1,59 @@
+import { NoSymbolIcon } from "@heroicons/react/24/solid";
+import { DialogClose } from "@radix-ui/react-dialog";
+import { Form, useNavigation } from "@remix-run/react";
+import { Button } from "~/components/primitives/Buttons";
+import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { SpinnerWhite } from "~/components/primitives/Spinner";
+
+type AbortBulkActionDialogProps = {
+ // The abort action route to POST to (the bulk action detail path).
+ formAction: string;
+ // Fired on submit so a parent controlling the Radix Dialog can close it
+ // without wrapping the submit button in `DialogClose` — that wrapper races
+ // submit (close fires first, unmounts the form, and the abort POST never
+ // lands). Optional so uncontrolled call sites still type-check.
+ onAbortSubmitted?: () => void;
+};
+
+export function AbortBulkActionDialog({
+ formAction,
+ onAbortSubmitted,
+}: AbortBulkActionDialogProps) {
+ const navigation = useNavigation();
+
+ const isLoading = navigation.formAction === formAction && navigation.formMethod === "POST";
+
+ return (
+
+ Abort this bulk action?
+
+
+ Aborting stops this bulk action from processing any remaining runs. Runs it has already
+ processed won't be affected.
+
+
onAbortSubmitted?.()}>
+
+
+ }
+ cancelButton={
+
+
+
+ }
+ />
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx
index 0b6ffb50ed..d949d1ad7c 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx
@@ -1,20 +1,22 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
-import { Form } from "@remix-run/react";
+import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { tryCatch } from "@trigger.dev/core";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
+import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary";
-import { LinkButton } from "~/components/primitives/Buttons";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
+import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
-import { PermissionButton } from "~/components/primitives/PermissionButton";
import * as Property from "~/components/primitives/PropertyTable";
+import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { env } from "~/env.server";
@@ -183,16 +185,10 @@ export default function Page() {
@@ -358,3 +354,28 @@ function typeText(type: BulkActionType) {
return "replayed";
}
}
+
+function ControlledAbortBulkActionDialog({
+ canAbort,
+ formAction,
+}: {
+ canAbort: boolean;
+ formAction: string;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx
index ff209f417b..795d08bc09 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx
@@ -1,4 +1,5 @@
import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid";
+import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
@@ -7,8 +8,9 @@ import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { BulkActionsNone } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
-import { LinkButton } from "~/components/primitives/Buttons";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
+import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -24,11 +26,13 @@ import {
TableBlankRow,
TableBody,
TableCell,
+ TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
+import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { useEnvironment } from "~/hooks/useEnvironment";
@@ -40,6 +44,8 @@ import {
type BulkActionListItem,
BulkActionListPresenter,
} from "~/presenters/v3/BulkActionListPresenter.server";
+import { rbac } from "~/services/rbac.server";
+import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
@@ -92,7 +98,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Error(error.message);
}
- return typedjson(data);
+ // Display flag for the row-menu Abort control. The abort action route
+ // enforces write:runs independently. Permissive in OSS.
+ const bulkAuth = await rbac.authenticateSession(request, {
+ userId,
+ organizationId: project.organizationId,
+ });
+ const { canAbort } = bulkAuth.ok
+ ? checkPermissions(bulkAuth.ability, {
+ canAbort: { action: "write", resource: { type: "runs" } },
+ })
+ : { canAbort: true };
+
+ return typedjson({ ...data, canAbort });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -108,6 +126,7 @@ export default function Page() {
currentPage,
totalPages,
totalCount: _totalCount,
+ canAbort,
} = useTypedLoaderData
();
const organization = useOrganization();
const project = useProject();
@@ -164,7 +183,11 @@ export default function Page() {
)}
-