22
33import { parseWorkflowName } from '@workflow/core/parse-name' ;
44import {
5+ cancelRun ,
56 type EnvMap ,
67 type Event ,
78 getErrorMessage ,
@@ -15,16 +16,17 @@ import {
1516 ArrowUpAZ ,
1617 ChevronLeft ,
1718 ChevronRight ,
18- Loader2Icon ,
19+ Loader2 ,
1920 MoreHorizontal ,
2021 RefreshCw ,
22+ XCircle ,
2123} from 'lucide-react' ;
2224import { usePathname , useRouter , useSearchParams } from 'next/navigation' ;
2325import { useCallback , useEffect , useMemo , useState } from 'react' ;
26+ import { toast } from 'sonner' ;
2427import { Alert , AlertDescription , AlertTitle } from '@/components/ui/alert' ;
2528import { Button } from '@/components/ui/button' ;
2629import { Card , CardContent } from '@/components/ui/card' ;
27- import { DocsLink } from '@/components/ui/docs-link' ;
2830import {
2931 DropdownMenu ,
3032 DropdownMenuContent ,
@@ -53,11 +55,14 @@ import {
5355import { worldConfigToEnvMap } from '@/lib/config' ;
5456import type { WorldConfig } from '@/lib/config-world' ;
5557import { useDataDirInfo } from '@/lib/hooks' ;
58+ import { useTableSelection } from '@/lib/hooks/use-table-selection' ;
5659import { CopyableText } from './display-utils/copyable-text' ;
5760import { RelativeTime } from './display-utils/relative-time' ;
61+ import { SelectionBar } from './display-utils/selection-bar' ;
5862import { StatusBadge } from './display-utils/status-badge' ;
5963import { TableSkeleton } from './display-utils/table-skeleton' ;
6064import { RunActionsDropdownItems } from './run-actions' ;
65+ import { Checkbox } from './ui/checkbox' ;
6166
6267// Inner content that fetches events when it mounts (only rendered when dropdown is open)
6368function RunActionsDropdownContentInner ( {
@@ -408,6 +413,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
408413 status : status === 'all' ? undefined : status ,
409414 } ) ;
410415
416+ // Multi-select functionality
417+ const selection = useTableSelection < WorkflowRun > ( {
418+ getItemId : ( run ) => run . runId ,
419+ } ) ;
420+
421+ const runs = data . data ?? [ ] ;
422+
423+ // Bulk cancel state
424+ const [ isBulkCancelling , setIsBulkCancelling ] = useState ( false ) ;
425+
411426 const isLocalAndHasMissingData =
412427 isLocal &&
413428 ( ! dataDirInfo ?. dataDir || ! data ?. data ?. length ) &&
@@ -434,6 +449,54 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
434449 reload ( ) ;
435450 } , [ reload ] ) ;
436451
452+ // Get selected runs that are cancellable (pending or running)
453+ const selectedRuns = useMemo ( ( ) => {
454+ return runs . filter ( ( run ) => selection . selectedIds . has ( run . runId ) ) ;
455+ } , [ runs , selection . selectedIds ] ) ;
456+
457+ const cancellableSelectedRuns = useMemo ( ( ) => {
458+ return selectedRuns . filter (
459+ ( run ) => run . status === 'pending' || run . status === 'running'
460+ ) ;
461+ } , [ selectedRuns ] ) ;
462+
463+ const hasCancellableSelection = cancellableSelectedRuns . length > 0 ;
464+
465+ const handleBulkCancel = useCallback ( async ( ) => {
466+ if ( isBulkCancelling || cancellableSelectedRuns . length === 0 ) return ;
467+
468+ setIsBulkCancelling ( true ) ;
469+ try {
470+ const results = await Promise . allSettled (
471+ cancellableSelectedRuns . map ( ( run ) => cancelRun ( env , run . runId ) )
472+ ) ;
473+
474+ const succeeded = results . filter ( ( r ) => r . status === 'fulfilled' ) . length ;
475+ const failed = results . filter ( ( r ) => r . status === 'rejected' ) . length ;
476+
477+ if ( failed === 0 ) {
478+ toast . success (
479+ `Cancelled ${ succeeded } run${ succeeded !== 1 ? 's' : '' } `
480+ ) ;
481+ } else if ( succeeded === 0 ) {
482+ toast . error ( `Failed to cancel ${ failed } run${ failed !== 1 ? 's' : '' } ` ) ;
483+ } else {
484+ toast . warning (
485+ `Cancelled ${ succeeded } run${ succeeded !== 1 ? 's' : '' } , ${ failed } failed`
486+ ) ;
487+ }
488+
489+ selection . clearSelection ( ) ;
490+ onReload ( ) ;
491+ } catch ( err ) {
492+ toast . error ( 'Failed to cancel runs' , {
493+ description : err instanceof Error ? err . message : 'Unknown error' ,
494+ } ) ;
495+ } finally {
496+ setIsBulkCancelling ( false ) ;
497+ }
498+ } , [ env , cancellableSelectedRuns , isBulkCancelling , selection , onReload ] ) ;
499+
437500 const toggleSortOrder = ( ) => {
438501 setSortOrder ( ( prev ) => ( prev === 'desc' ? 'asc' : 'desc' ) ) ;
439502 } ;
@@ -501,6 +564,14 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
501564 < Table >
502565 < TableHeader >
503566 < TableRow >
567+ < TableHead className = "sticky top-0 bg-background z-10 border-b shadow-sm h-10 w-10" >
568+ < Checkbox
569+ checked = { selection . isAllSelected ( runs ) }
570+ indeterminate = { selection . isSomeSelected ( runs ) }
571+ onCheckedChange = { ( ) => selection . toggleSelectAll ( runs ) }
572+ aria-label = "Select all runs"
573+ />
574+ </ TableHead >
504575 < TableHead className = "sticky top-0 bg-background z-10 border-b shadow-sm h-10" >
505576 Workflow
506577 </ TableHead >
@@ -520,12 +591,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
520591 </ TableRow >
521592 </ TableHeader >
522593 < TableBody >
523- { data . data ? .map ( ( run ) => (
594+ { runs . map ( ( run ) => (
524595 < TableRow
525596 key = { run . runId }
526597 className = "cursor-pointer group relative"
527598 onClick = { ( ) => onRunClick ( run . runId ) }
599+ data-selected = { selection . isSelected ( run ) }
528600 >
601+ < TableCell className = "py-2" >
602+ < Checkbox
603+ checked = { selection . isSelected ( run ) }
604+ onCheckedChange = { ( ) => selection . toggleSelection ( run ) }
605+ aria-label = { `Select run ${ run . runId } ` }
606+ />
607+ </ TableCell >
529608 < TableCell className = "py-2" >
530609 < CopyableText text = { run . workflowName } overlay >
531610 { parseWorkflowName ( run . workflowName ) ?. shortName ||
@@ -606,6 +685,34 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
606685 </ div >
607686 </ >
608687 ) }
688+
689+ < SelectionBar
690+ selectionCount = { selection . selectionCount }
691+ onClearSelection = { selection . clearSelection }
692+ itemLabel = "runs"
693+ actions = {
694+ hasCancellableSelection && (
695+ < Button
696+ variant = "ghost"
697+ size = "sm"
698+ className = "h-7 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
699+ onClick = { handleBulkCancel }
700+ disabled = { isBulkCancelling }
701+ >
702+ { isBulkCancelling ? (
703+ < Loader2 className = "h-4 w-4 mr-1 animate-spin" />
704+ ) : (
705+ < XCircle className = "h-4 w-4 mr-1" />
706+ ) }
707+ Cancel{ ' ' }
708+ { cancellableSelectedRuns . length !== selection . selectionCount
709+ ? `${ cancellableSelectedRuns . length } `
710+ : '' }
711+ { isBulkCancelling ? 'cancelling...' : '' }
712+ </ Button >
713+ )
714+ }
715+ />
609716 </ div >
610717 ) ;
611718}
0 commit comments