Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/flat-cloths-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/web": patch
---

Allow selecting and cancelling multiple runs from table view
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions packages/web/src/components/display-utils/selection-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-2.5 rounded-lg',
'bg-primary text-primary-foreground shadow-lg',
'animate-in fade-in slide-in-from-bottom-4 duration-200',
className
)}
>
<span className="text-sm font-medium">
{selectionCount} {label} selected
</span>

{actions && (
<div className="flex items-center gap-2 border-l border-primary-foreground/20 pl-3">
{actions}
</div>
)}

<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
onClick={onClearSelection}
>
<X className="h-4 w-4" />
<span className="sr-only">Clear selection</span>
</Button>
</div>
);
}
113 changes: 110 additions & 3 deletions packages/web/src/components/runs-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { parseWorkflowName } from '@workflow/core/parse-name';
import {
cancelRun,
type EnvMap,
type Event,
getErrorMessage,
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -408,6 +413,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
status: status === 'all' ? undefined : status,
});

// Multi-select functionality
const selection = useTableSelection<WorkflowRun>({
getItemId: (run) => run.runId,
});

const runs = data.data ?? [];

// Bulk cancel state
const [isBulkCancelling, setIsBulkCancelling] = useState(false);

const isLocalAndHasMissingData =
isLocal &&
(!dataDirInfo?.dataDir || !data?.data?.length) &&
Expand All @@ -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'));
};
Expand Down Expand Up @@ -501,6 +564,14 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
<Table>
<TableHeader>
<TableRow>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10 w-10">
<Checkbox
checked={selection.isAllSelected(runs)}
indeterminate={selection.isSomeSelected(runs)}
onCheckedChange={() => selection.toggleSelectAll(runs)}
aria-label="Select all runs"
/>
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Workflow
</TableHead>
Expand All @@ -520,12 +591,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
</TableRow>
</TableHeader>
<TableBody>
{data.data?.map((run) => (
{runs.map((run) => (
<TableRow
key={run.runId}
className="cursor-pointer group relative"
onClick={() => onRunClick(run.runId)}
data-selected={selection.isSelected(run)}
>
<TableCell className="py-2">
<Checkbox
checked={selection.isSelected(run)}
onCheckedChange={() => selection.toggleSelection(run)}
aria-label={`Select run ${run.runId}`}
/>
</TableCell>
<TableCell className="py-2">
<CopyableText text={run.workflowName} overlay>
{parseWorkflowName(run.workflowName)?.shortName ||
Expand Down Expand Up @@ -606,6 +685,34 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
</div>
</>
)}

<SelectionBar
selectionCount={selection.selectionCount}
onClearSelection={selection.clearSelection}
itemLabel="runs"
actions={
hasCancellableSelection && (
<Button
variant="ghost"
size="sm"
className="h-7 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
onClick={handleBulkCancel}
disabled={isBulkCancelling}
>
{isBulkCancelling ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<XCircle className="h-4 w-4 mr-1" />
)}
Cancel{' '}
{cancellableSelectedRuns.length !== selection.selectionCount
? `${cancellableSelectedRuns.length} `
: ''}
{isBulkCancelling ? 'cancelling...' : ''}
</Button>
)
}
/>
</div>
);
}
74 changes: 74 additions & 0 deletions packages/web/src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CheckboxPrimitive.Root>,
'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<typeof CheckboxPrimitive.Root>,
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
<label
htmlFor={checkboxId}
className="p-2 -m-2 inline-flex items-center justify-center cursor-pointer"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<CheckboxPrimitive.Root
ref={ref}
id={checkboxId}
checked={checkedState}
onCheckedChange={(state: CheckboxPrimitive.CheckedState) => {
// Convert Radix's CheckedState back to boolean
// 'indeterminate' becomes false on click (standard behavior)
onCheckedChange?.(state === true);
}}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
{indeterminate ? (
<Minus className="h-3.5 w-3.5" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</label>
);
});
Checkbox.displayName = 'Checkbox';

export { Checkbox };
Loading