diff --git a/.changeset/flat-cloths-cough.md b/.changeset/flat-cloths-cough.md
new file mode 100644
index 000000000..7464ebe3e
--- /dev/null
+++ b/.changeset/flat-cloths-cough.md
@@ -0,0 +1,5 @@
+---
+"@workflow/web": patch
+---
+
+Allow selecting and cancelling multiple runs from table view
diff --git a/packages/web/package.json b/packages/web/package.json
index 86cdc52a4..115fe59e1 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -34,6 +34,7 @@
"devDependencies": {
"@biomejs/biome": "catalog:",
"@radix-ui/react-alert-dialog": "1.1.5",
+ "@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.1.1",
diff --git a/packages/web/src/components/display-utils/selection-bar.tsx b/packages/web/src/components/display-utils/selection-bar.tsx
new file mode 100644
index 000000000..6787f4f95
--- /dev/null
+++ b/packages/web/src/components/display-utils/selection-bar.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { X } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+export interface SelectionBarProps {
+ /** Number of selected items */
+ selectionCount: number;
+ /** Callback to clear selection */
+ onClearSelection: () => void;
+ /** Optional action buttons to render */
+ actions?: ReactNode;
+ /** Label for what type of items are selected (e.g., "runs", "hooks") */
+ itemLabel?: string;
+ /** Additional className */
+ className?: string;
+}
+
+/**
+ * A floating bar that appears when items are selected in a table.
+ * Shows selection count and provides actions for bulk operations.
+ */
+export function SelectionBar({
+ selectionCount,
+ onClearSelection,
+ actions,
+ itemLabel = 'items',
+ className,
+}: SelectionBarProps) {
+ if (selectionCount === 0) {
+ return null;
+ }
+
+ const label = selectionCount === 1 ? itemLabel.replace(/s$/, '') : itemLabel;
+
+ return (
+
+
+ {selectionCount} {label} selected
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx
index 5a6e12fd6..f4d7729d2 100644
--- a/packages/web/src/components/runs-table.tsx
+++ b/packages/web/src/components/runs-table.tsx
@@ -2,6 +2,7 @@
import { parseWorkflowName } from '@workflow/core/parse-name';
import {
+ cancelRun,
type EnvMap,
type Event,
getErrorMessage,
@@ -15,16 +16,17 @@ import {
ArrowUpAZ,
ChevronLeft,
ChevronRight,
- Loader2Icon,
+ Loader2,
MoreHorizontal,
RefreshCw,
+ XCircle,
} from 'lucide-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
-import { DocsLink } from '@/components/ui/docs-link';
import {
DropdownMenu,
DropdownMenuContent,
@@ -53,11 +55,14 @@ import {
import { worldConfigToEnvMap } from '@/lib/config';
import type { WorldConfig } from '@/lib/config-world';
import { useDataDirInfo } from '@/lib/hooks';
+import { useTableSelection } from '@/lib/hooks/use-table-selection';
import { CopyableText } from './display-utils/copyable-text';
import { RelativeTime } from './display-utils/relative-time';
+import { SelectionBar } from './display-utils/selection-bar';
import { StatusBadge } from './display-utils/status-badge';
import { TableSkeleton } from './display-utils/table-skeleton';
import { RunActionsDropdownItems } from './run-actions';
+import { Checkbox } from './ui/checkbox';
// Inner content that fetches events when it mounts (only rendered when dropdown is open)
function RunActionsDropdownContentInner({
@@ -408,6 +413,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
status: status === 'all' ? undefined : status,
});
+ // Multi-select functionality
+ const selection = useTableSelection({
+ getItemId: (run) => run.runId,
+ });
+
+ const runs = data.data ?? [];
+
+ // Bulk cancel state
+ const [isBulkCancelling, setIsBulkCancelling] = useState(false);
+
const isLocalAndHasMissingData =
isLocal &&
(!dataDirInfo?.dataDir || !data?.data?.length) &&
@@ -434,6 +449,54 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
reload();
}, [reload]);
+ // Get selected runs that are cancellable (pending or running)
+ const selectedRuns = useMemo(() => {
+ return runs.filter((run) => selection.selectedIds.has(run.runId));
+ }, [runs, selection.selectedIds]);
+
+ const cancellableSelectedRuns = useMemo(() => {
+ return selectedRuns.filter(
+ (run) => run.status === 'pending' || run.status === 'running'
+ );
+ }, [selectedRuns]);
+
+ const hasCancellableSelection = cancellableSelectedRuns.length > 0;
+
+ const handleBulkCancel = useCallback(async () => {
+ if (isBulkCancelling || cancellableSelectedRuns.length === 0) return;
+
+ setIsBulkCancelling(true);
+ try {
+ const results = await Promise.allSettled(
+ cancellableSelectedRuns.map((run) => cancelRun(env, run.runId))
+ );
+
+ const succeeded = results.filter((r) => r.status === 'fulfilled').length;
+ const failed = results.filter((r) => r.status === 'rejected').length;
+
+ if (failed === 0) {
+ toast.success(
+ `Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}`
+ );
+ } else if (succeeded === 0) {
+ toast.error(`Failed to cancel ${failed} run${failed !== 1 ? 's' : ''}`);
+ } else {
+ toast.warning(
+ `Cancelled ${succeeded} run${succeeded !== 1 ? 's' : ''}, ${failed} failed`
+ );
+ }
+
+ selection.clearSelection();
+ onReload();
+ } catch (err) {
+ toast.error('Failed to cancel runs', {
+ description: err instanceof Error ? err.message : 'Unknown error',
+ });
+ } finally {
+ setIsBulkCancelling(false);
+ }
+ }, [env, cancellableSelectedRuns, isBulkCancelling, selection, onReload]);
+
const toggleSortOrder = () => {
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
};
@@ -501,6 +564,14 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
+
+ selection.toggleSelectAll(runs)}
+ aria-label="Select all runs"
+ />
+
Workflow
@@ -520,12 +591,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
- {data.data?.map((run) => (
+ {runs.map((run) => (
onRunClick(run.runId)}
+ data-selected={selection.isSelected(run)}
>
+
+ selection.toggleSelection(run)}
+ aria-label={`Select run ${run.runId}`}
+ />
+
{parseWorkflowName(run.workflowName)?.shortName ||
@@ -606,6 +685,34 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
>
)}
+
+
+ {isBulkCancelling ? (
+
+ ) : (
+
+ )}
+ Cancel{' '}
+ {cancellableSelectedRuns.length !== selection.selectionCount
+ ? `${cancellableSelectedRuns.length} `
+ : ''}
+ {isBulkCancelling ? 'cancelling...' : ''}
+
+ )
+ }
+ />
);
}
diff --git a/packages/web/src/components/ui/checkbox.tsx b/packages/web/src/components/ui/checkbox.tsx
new file mode 100644
index 000000000..4105f30b8
--- /dev/null
+++ b/packages/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
+import { Check, Minus } from 'lucide-react';
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export interface CheckboxProps
+ extends Omit<
+ React.ComponentPropsWithoutRef,
+ 'checked' | 'onCheckedChange'
+ > {
+ /** Whether the checkbox is checked */
+ checked?: boolean;
+ /** Whether the checkbox is in indeterminate state (for "select all" with partial selection) */
+ indeterminate?: boolean;
+ /** Callback when checked state changes */
+ onCheckedChange?: (checked: boolean) => void;
+}
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ CheckboxProps
+>(({ className, checked, indeterminate, onCheckedChange, ...props }, ref) => {
+ // Generate a unique id for associating label with checkbox
+ const id = React.useId();
+ const checkboxId = props.id ?? id;
+
+ // Convert our boolean props to Radix's CheckedState
+ const checkedState: CheckboxPrimitive.CheckedState = indeterminate
+ ? 'indeterminate'
+ : (checked ?? false);
+
+ return (
+ // Label provides the click grace area and proper a11y association
+
+ );
+});
+Checkbox.displayName = 'Checkbox';
+
+export { Checkbox };
diff --git a/packages/web/src/lib/hooks/use-table-selection.ts b/packages/web/src/lib/hooks/use-table-selection.ts
new file mode 100644
index 000000000..d9058a22b
--- /dev/null
+++ b/packages/web/src/lib/hooks/use-table-selection.ts
@@ -0,0 +1,153 @@
+import { useCallback, useMemo, useState } from 'react';
+
+export interface UseTableSelectionOptions {
+ /** Function to extract unique ID from each item */
+ getItemId: (item: T) => string;
+}
+
+export interface UseTableSelectionReturn {
+ /** Set of currently selected item IDs */
+ selectedIds: Set;
+ /** Whether a specific item is selected */
+ isSelected: (item: T) => boolean;
+ /** Toggle selection of a single item */
+ toggleSelection: (item: T) => void;
+ /** Select all items in the provided list */
+ selectAll: (items: T[]) => void;
+ /** Clear all selections */
+ clearSelection: () => void;
+ /** Toggle all items (select all if not all selected, otherwise clear) */
+ toggleSelectAll: (items: T[]) => void;
+ /** Number of selected items */
+ selectionCount: number;
+ /** Whether all provided items are selected */
+ isAllSelected: (items: T[]) => boolean;
+ /** Whether some but not all items are selected (for indeterminate state) */
+ isSomeSelected: (items: T[]) => boolean;
+ /** Select a specific item by ID */
+ selectById: (id: string) => void;
+ /** Deselect a specific item by ID */
+ deselectById: (id: string) => void;
+}
+
+/**
+ * Hook for managing table row selection state.
+ * Provides a consistent interface for multi-select functionality across different tables.
+ */
+export function useTableSelection({
+ getItemId,
+}: UseTableSelectionOptions): UseTableSelectionReturn {
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const isSelected = useCallback(
+ (item: T) => selectedIds.has(getItemId(item)),
+ [selectedIds, getItemId]
+ );
+
+ const toggleSelection = useCallback(
+ (item: T) => {
+ const id = getItemId(item);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ },
+ [getItemId]
+ );
+
+ const selectAll = useCallback(
+ (items: T[]) => {
+ setSelectedIds(new Set(items.map(getItemId)));
+ },
+ [getItemId]
+ );
+
+ const clearSelection = useCallback(() => {
+ setSelectedIds(new Set());
+ }, []);
+
+ const isAllSelected = useCallback(
+ (items: T[]) => {
+ if (items.length === 0) return false;
+ return items.every((item) => selectedIds.has(getItemId(item)));
+ },
+ [selectedIds, getItemId]
+ );
+
+ const isSomeSelected = useCallback(
+ (items: T[]) => {
+ if (items.length === 0) return false;
+ const someSelected = items.some((item) =>
+ selectedIds.has(getItemId(item))
+ );
+ const allSelected = items.every((item) =>
+ selectedIds.has(getItemId(item))
+ );
+ return someSelected && !allSelected;
+ },
+ [selectedIds, getItemId]
+ );
+
+ const toggleSelectAll = useCallback(
+ (items: T[]) => {
+ if (isAllSelected(items)) {
+ clearSelection();
+ } else {
+ selectAll(items);
+ }
+ },
+ [isAllSelected, clearSelection, selectAll]
+ );
+
+ const selectById = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ next.add(id);
+ return next;
+ });
+ }, []);
+
+ const deselectById = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }, []);
+
+ const selectionCount = selectedIds.size;
+
+ return useMemo(
+ () => ({
+ selectedIds,
+ isSelected,
+ toggleSelection,
+ selectAll,
+ clearSelection,
+ toggleSelectAll,
+ selectionCount,
+ isAllSelected,
+ isSomeSelected,
+ selectById,
+ deselectById,
+ }),
+ [
+ selectedIds,
+ isSelected,
+ toggleSelection,
+ selectAll,
+ clearSelection,
+ toggleSelectAll,
+ selectionCount,
+ isAllSelected,
+ isSomeSelected,
+ selectById,
+ deselectById,
+ ]
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3bc68acda..b92f85d24 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -845,6 +845,9 @@ importers:
'@radix-ui/react-alert-dialog':
specifier: 1.1.5
version: 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-checkbox':
+ specifier: 1.1.4
+ version: 1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-dialog':
specifier: 1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -4653,6 +4656,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-checkbox@1.1.4':
+ resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
@@ -16546,6 +16562,22 @@ snapshots:
'@types/react': 19.1.13
'@types/react-dom': 19.1.9(@types/react@19.1.13)
+ '@radix-ui/react-checkbox@1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.1
+ '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0)
+ '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0)
+ '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.13)(react@19.1.0)
+ '@radix-ui/react-use-previous': 1.1.0(@types/react@19.1.13)(react@19.1.0)
+ '@radix-ui/react-use-size': 1.1.0(@types/react@19.1.13)(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.13
+ '@types/react-dom': 19.1.9(@types/react@19.1.13)
+
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3