From 905826ff7918eac746513393ffb49fcbaa11ebd0 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 3 Jul 2026 14:56:27 +0100 Subject: [PATCH 1/6] feat(webapp): add Abort button to bulk actions list rows The Bulk actions list did not surface any per-row actions, so aborting an in-progress bulk action meant opening a row's inspector to reach its Abort button. This adds an Abort button that appears on row hover (the same pattern as the Test button on the Tasks list) for bulk actions that are still running. The button submits to the existing abort action, so it does exactly what the inspector's Abort button does. It respects write:runs and is shown disabled with a tooltip when the user lacks permission. --- .../route.tsx | 87 +++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) 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..d9a96fc4d3 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,5 +1,5 @@ -import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; +import { BookOpenIcon, NoSymbolIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { Form, Outlet, useParams, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -7,7 +7,7 @@ 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 { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; @@ -24,6 +24,7 @@ import { TableBlankRow, TableBody, TableCell, + TableCellMenu, TableHeader, TableHeaderCell, TableRow, @@ -40,6 +41,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 +95,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 +123,7 @@ export default function Page() { currentPage, totalPages, totalCount: _totalCount, + canAbort, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -164,7 +180,11 @@ export default function Page() { )} - + {totalPages > 1 && (
User Created Completed + + Actions + {bulkActions.length === 0 ? ( - There are no matching bulk actions + There are no matching bulk actions ) : ( bulkActions.map((bulkAction) => { const path = v3BulkActionPath(organization, project, environment, bulkAction); @@ -306,6 +331,11 @@ function BulkActionsTable({ {bulkAction.completedAt ? : "–"} + ); }) @@ -314,3 +344,48 @@ function BulkActionsTable({ ); } + +function BulkActionActionsCell({ + bulkAction, + path, + canAbort, +}: { + bulkAction: BulkActionListItem; + path: string; + canAbort: boolean; +}) { + // Abort is the only action, and only while the bulk action is still running. + if (bulkAction.status !== "PENDING") { + return {""}; + } + + return ( + + + + ) : ( + + ) + } + /> + ); +} From ac634e7884b049ebcc42e0f9c818e2f31f66c29c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 3 Jul 2026 15:03:15 +0100 Subject: [PATCH 2/6] format --- .../route.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 d9a96fc4d3..e83b0a63d1 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 @@ -331,11 +331,7 @@ function BulkActionsTable({ {bulkAction.completedAt ? : "–"} - + ); }) From d3daaca8135a897c092c583ea00e62a3129e66ac Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 3 Jul 2026 15:13:15 +0100 Subject: [PATCH 3/6] feat(webapp): confirm before aborting a bulk action Aborting a bulk action from the list row button or the inspector now opens a confirmation dialog (matching the "Cancel this run?" flow) instead of firing immediately, so it's harder to abort by accident. Both entry points share a new AbortBulkActionDialog that posts to the existing abort action. --- .../runs/v3/AbortBulkActionDialog.tsx | 56 +++++++++++++++++++ .../route.tsx | 47 +++++++++++----- .../route.tsx | 29 ++++++---- 3 files changed, 107 insertions(+), 25 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx 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..93753ff142 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx @@ -0,0 +1,56 @@ +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..1bf8590162 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() {
{bulkAction.status === "PENDING" ? ( -
- - Abort bulk action - -
+ ) : null}
@@ -358,3 +354,28 @@ function typeText(type: BulkActionType) { return "replayed"; } } + +function ControlledAbortBulkActionDialog({ + canAbort, + formAction, +}: { + canAbort: boolean; + formAction: string; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + setOpen(false)} /> + + ); +} 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 e83b0a63d1..2b81361ff3 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,5 +1,6 @@ -import { BookOpenIcon, NoSymbolIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { Form, Outlet, useParams, type MetaFunction } from "@remix-run/react"; +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"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -9,6 +10,7 @@ import { BulkActionsNone } from "~/components/BlankStatePanels"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; 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"; @@ -30,6 +32,7 @@ import { 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"; @@ -360,16 +363,18 @@ function BulkActionActionsCell({ isSticky hiddenButtons={ canAbort ? ( -
- -
+ + + + + + ) : ( setOpen(false)} /> 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 2b81361ff3..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 @@ -370,7 +370,7 @@ function BulkActionActionsCell({ LeadingIcon={NoSymbolIcon} leadingIconClassName="text-error" > - Abort + Abort… @@ -383,7 +383,7 @@ function BulkActionActionsCell({ disabled tooltip="You don't have permission to abort bulk actions" > - Abort + Abort… ) } From 883be7fba72810256fadf15393eb8f9837ff4ecb Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 3 Jul 2026 15:17:31 +0100 Subject: [PATCH 5/6] format --- apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx index 93753ff142..e895961140 100644 --- a/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx @@ -17,7 +17,10 @@ type AbortBulkActionDialogProps = { onAbortSubmitted?: () => void; }; -export function AbortBulkActionDialog({ formAction, onAbortSubmitted }: AbortBulkActionDialogProps) { +export function AbortBulkActionDialog({ + formAction, + onAbortSubmitted, +}: AbortBulkActionDialogProps) { const navigation = useNavigation(); const isLoading = navigation.formAction === formAction && navigation.formMethod === "POST"; From e6e8e984a541147ea3067cefe5702064c4c4021c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Fri, 3 Jul 2026 15:32:16 +0100 Subject: [PATCH 6/6] chore(webapp): add server-changes for bulk action abort from list --- .server-changes/bulk-action-abort-from-list.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/bulk-action-abort-from-list.md 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.