Files
vercel__workflow/packages/web/app/components/runs-table.tsx
Nathan Rajlich 7653e6bfdb Migrate @workflow/web from Next.js to React Router v7 (#1005)
## Summary

- Replace Next.js App Router with React Router v7.13.0 framework mode (Vite-based), eliminating the large `next` dependency from the web, CLI, and workflow metapackages
- Serve the web UI in-process from the CLI via Express instead of spawning `next start` as a child process
- Switch RPC transport from JSON to CBOR to preserve binary data types across the wire
- Replace `nuqs` URL state management with React Router's `useSearchParams`
- Replace Next.js server actions with an RPC resource route (`/api/rpc`) and a thin CBOR-based client

## Motivation

The `next` package is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.

With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.

## What changed

**Framework swap (`@workflow/web`):**
- `next.config.ts` / `postcss.config.mjs` → `react-router.config.ts` / `vite.config.ts`
- `src/` directory → `app/` directory (React Router convention)
- `src/app/layout.tsx` + `layout-client.tsx` → `app/root.tsx`
- `src/app/page.tsx` → `app/routes/home.tsx`
- `src/app/run/[runId]/page.tsx` → `app/routes/run-detail.tsx`
- Path alias `@/` → `~/`
- Removed all `'use client'` / `'use server'` directives

**Data transport:**
- Server actions → RPC resource route at `/api/rpc` with CBOR encoding
- CBOR preserves `Uint8Array` and other binary types natively (no base64 overhead)
- Stream reading → dedicated `/api/stream/:streamId` resource route

**URL state:**
- `nuqs` (`useQueryState`) → `useSearchParams` from `react-router`

**Fonts:**
- `next/font/google` → Geist `.woff2` files referenced directly from `node_modules/geist` via `@font-face` in CSS

**CLI integration (`@workflow/cli`):**
- `import('@workflow/web/server').then(m => m.startServer(port))`
- No child process, no readiness polling, no cleanup handlers

**Radix UI compatibility:**
- `onSubmit` preventDefault on `AlertDialogContent` and `SheetContent` to prevent Radix's internal `<form method="dialog">` from triggering React Router route actions
- Catch-all action on root route for any stray POSTs

## Dependencies removed
- `next`, `swr`, `nuqs`, `@tailwindcss/postcss`

## Dependencies added
- `react-router` / `@react-router/dev` / `@react-router/node` / `@react-router/express` (all `7.13.0`)
- `express`, `vite`, `@tailwindcss/vite`, `cbor-x`, `isbot`, `cross-env`
- `geist` (devDep)
2026-02-12 16:30:51 -08:00

833 lines
28 KiB
TypeScript

import { parseWorkflowName } from '@workflow/utils/parse-name';
import type { Event, WorkflowRun, WorkflowRunStatus } from '@workflow/world';
import {
AlertCircle,
ArrowDownAZ,
ArrowUpAZ,
ChevronLeft,
ChevronRight,
Loader2,
MoreHorizontal,
RefreshCw,
XCircle,
Zap,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '~/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '~/components/ui/tooltip';
import { useTableSelection } from '~/lib/hooks/use-table-selection';
import {
cancelRun,
getErrorMessage,
reenqueueRun,
useWorkflowRuns,
} from '~/lib/workflow-api-client';
import { useServerConfig } from '~/lib/world-config-context';
import type { EnvMap } from '~/lib/types';
import { fetchEvents, fetchRun } from '~/lib/rpc-client';
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 { 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({
env,
runId,
runStatus,
onSuccess,
}: {
env: EnvMap;
runId: string;
runStatus: WorkflowRunStatus | undefined;
onSuccess: () => void;
}) {
const [events, setEvents] = useState<Event[] | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [run, setRun] = useState<WorkflowRun | undefined>(undefined);
const status = run?.status || runStatus;
useEffect(() => {
setIsLoading(true);
Promise.all([
fetchRun(env, runId, 'none'),
fetchEvents(env, runId, { limit: 1000, sortOrder: 'desc' }),
])
.then(([runResult, eventsResult]) => {
if (runResult.success) {
setRun(runResult.data);
}
if (eventsResult.success) {
setEvents(eventsResult.data.data);
}
})
.catch((err: unknown) => {
console.error('Failed to fetch run or events:', err);
})
.finally(() => {
setIsLoading(false);
});
}, [env, runId]);
return (
<RunActionsDropdownItems
env={env}
runId={runId}
runStatus={status}
events={events}
eventsLoading={isLoading}
stopPropagation
callbacks={{ onSuccess }}
/>
);
}
// Wrapper that only renders content when dropdown is open (lazy loading)
function LazyDropdownMenu({
env,
runId,
runStatus,
onSuccess,
}: {
env: EnvMap;
runId: string;
runStatus: WorkflowRunStatus | undefined;
onSuccess: () => void;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{isOpen && (
<DropdownMenuContent align="end">
<RunActionsDropdownContentInner
env={env}
runId={runId}
runStatus={runStatus}
onSuccess={onSuccess}
/>
</DropdownMenuContent>
)}
</DropdownMenu>
);
}
interface RunsTableProps {
onRunClick: (runId: string) => void;
}
const statusMap: Record<WorkflowRunStatus, { label: string; color: string }> = {
pending: { label: 'Pending', color: 'bg-neutral-600 dark:bg-neutral-400' },
running: { label: 'Running', color: 'bg-blue-600 dark:bg-blue-400' },
completed: { label: 'Completed', color: 'bg-green-600 dark:bg-green-400' },
failed: { label: 'Failed', color: 'bg-red-600 dark:bg-red-400' },
cancelled: { label: 'Cancelled', color: 'bg-gray-600 dark:bg-gray-400' },
};
// Helper: Handle workflow filter changes
function useWorkflowFilter() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
return useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value === 'all') {
params.delete('workflow');
params.delete('status');
} else {
params.set('workflow', value);
}
navigate(`${pathname}?${params.toString()}`);
},
[navigate, pathname, searchParams]
);
}
// Helper: Handle status filter changes
function useStatusFilter() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
return useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value === 'all') {
params.delete('status');
} else {
params.set('status', value);
}
navigate(`${pathname}?${params.toString()}`);
},
[navigate, pathname, searchParams]
);
}
// Filter controls component
interface FilterControlsProps {
workflowNameFilter: string | 'all';
status: WorkflowRunStatus | 'all' | undefined;
seenWorkflowNames: Set<string>;
sortOrder: 'asc' | 'desc';
loading: boolean;
statusFilterRequiresWorkflowNameFilter: boolean;
onWorkflowChange: (value: string) => void;
onStatusChange: (value: string) => void;
onSortToggle: () => void;
onRefresh: () => void;
lastRefreshTime: Date | null;
}
function FilterControls({
workflowNameFilter,
status,
seenWorkflowNames,
sortOrder,
loading,
statusFilterRequiresWorkflowNameFilter,
onWorkflowChange,
onStatusChange,
onSortToggle,
onRefresh,
lastRefreshTime,
}: FilterControlsProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-end gap-2">
<p className="text-sm text-muted-foreground">Last refreshed</p>
{lastRefreshTime && (
<RelativeTime
date={lastRefreshTime}
className="text-sm text-muted-foreground"
type="distance"
/>
)}
</div>
<div className="flex items-center gap-4">
<Select
value={workflowNameFilter ?? 'all'}
onValueChange={onWorkflowChange}
disabled={loading}
>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Filter by workflow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Workflows</SelectItem>
{Array.from(seenWorkflowNames)
.sort()
.map((name) => (
<SelectItem key={name} value={name}>
{parseWorkflowName(name)?.shortName || name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Select
value={status || 'all'}
onValueChange={onStatusChange}
disabled={
loading ||
(statusFilterRequiresWorkflowNameFilter &&
!workflowNameFilter)
}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any status</SelectItem>
{Object.entries(statusMap).map(
([status, { label, color }]) => (
<SelectItem key={status} value={status}>
<div className="flex items-center">
<span
className={`${color} size-1.5 rounded-full mr-2`}
/>
{label}
</div>
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent>
{statusFilterRequiresWorkflowNameFilter &&
workflowNameFilter === 'all'
? 'Select a workflow first to filter by status'
: 'Filter runs by status'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onSortToggle}
disabled={loading}
>
{sortOrder === 'desc' ? (
<ArrowDownAZ className="h-4 w-4" />
) : (
<ArrowUpAZ className="h-4 w-4" />
)}
{sortOrder === 'desc' ? 'Newest' : 'Oldest'}
</Button>
</TooltipTrigger>
<TooltipContent>
{sortOrder === 'desc'
? 'Showing newest first'
: 'Showing oldest first'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</Button>
</TooltipTrigger>
<TooltipContent>Note that this resets pages</TooltipContent>
</Tooltip>
</div>
</div>
);
}
/**
* RunsTable - Displays workflow runs with server-side pagination.
* Uses the PaginatingTable pattern: fetches data for each page as needed from the server.
* The table and fetching behavior are intertwined - pagination controls trigger new API calls.
*
* World configuration is read from server-side environment variables.
* The env object passed to server actions is empty - the server uses process.env.
*/
export function RunsTable({ onRunClick }: RunsTableProps) {
const [searchParams] = useSearchParams();
const handleWorkflowFilter = useWorkflowFilter();
const handleStatusFilter = useStatusFilter();
const { serverConfig } = useServerConfig();
// Validate status parameter - only allow known valid statuses or 'all'
const rawStatus = searchParams.get('status');
const validStatuses = Object.keys(statusMap) as WorkflowRunStatus[];
const status: WorkflowRunStatus | 'all' | undefined =
rawStatus === 'all' ||
(rawStatus && validStatuses.includes(rawStatus as WorkflowRunStatus))
? (rawStatus as WorkflowRunStatus | 'all')
: undefined;
const workflowNameFilter = searchParams.get('workflow') as string | 'all';
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(
() => new Date()
);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
// Empty env object - server actions read from process.env
const env: EnvMap = useMemo(() => ({}), []);
const isLocal =
serverConfig.backendId === 'local' ||
serverConfig.backendId === '@workflow/world-local';
const localDataDirPath = serverConfig.displayInfo?.['local.dataDirPath'];
const localShortName = serverConfig.displayInfo?.['local.shortName'];
// TODO: World-vercel doesn't support filtering by status without a workflow name filter
const statusFilterRequiresWorkflowNameFilter =
serverConfig.backendId?.includes('vercel') || false;
// TODO: This is a workaround. We should be getting a list of valid workflow names
// from the manifest.
const [seenWorkflowNames, setSeenWorkflowNames] = useState<Set<string>>(
new Set()
);
const {
data,
error,
nextPage,
previousPage,
hasNextPage,
hasPreviousPage,
reload,
refresh,
pageInfo,
} = useWorkflowRuns(env, {
sortOrder,
workflowName: workflowNameFilter === 'all' ? undefined : workflowNameFilter,
status: status === 'all' ? undefined : status,
});
// Multi-select functionality
const selection = useTableSelection<WorkflowRun>({
getItemId: (run) => run.runId,
});
const runs = data.data ?? [];
// Bulk action states
const [isBulkCancelling, setIsBulkCancelling] = useState(false);
const [isBulkReenqueuing, setIsBulkReenqueuing] = useState(false);
const isLocalAndHasMissingData =
isLocal && (!localDataDirPath || !data?.data?.length);
// Track seen workflow names from loaded data
useEffect(() => {
if (data.data && data.data.length > 0) {
const newNames = new Set(data.data.map((run) => run.workflowName));
setSeenWorkflowNames((prev) => {
const updated = new Set(prev);
for (const name of newNames) {
updated.add(name);
}
return updated;
});
}
}, [data.data]);
const loading = data.isLoading;
// Track when we've completed the initial load
useEffect(() => {
if (!loading && !hasLoadedOnce) {
setHasLoadedOnce(true);
}
}, [loading, hasLoadedOnce]);
// Reset hasLoadedOnce when filters change
// biome-ignore lint/correctness/useExhaustiveDependencies: Want to reset on any filter change
useEffect(() => {
setHasLoadedOnce(false);
}, [workflowNameFilter, status, sortOrder]);
const onReload = useCallback(() => {
setLastRefreshTime(() => new Date());
setHasLoadedOnce(false);
reload();
}, [reload]);
// Refresh current page without resetting state (prevents layout shift)
const onRefresh = useCallback(() => {
setLastRefreshTime(() => new Date());
refresh();
}, [refresh]);
// 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 handleBulkReenqueue = useCallback(async () => {
if (isBulkReenqueuing || selectedRuns.length === 0) return;
setIsBulkReenqueuing(true);
try {
const results = await Promise.allSettled(
selectedRuns.map((run) => reenqueueRun(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(
`Re-enqueued ${succeeded} run${succeeded !== 1 ? 's' : ''}`
);
} else if (succeeded === 0) {
toast.error(
`Failed to re-enqueue ${failed} run${failed !== 1 ? 's' : ''}`
);
} else {
toast.warning(
`Re-enqueued ${succeeded} run${succeeded !== 1 ? 's' : ''}, ${failed} failed`
);
}
selection.clearSelection();
onReload();
} catch (err) {
toast.error('Failed to re-enqueue runs', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsBulkReenqueuing(false);
}
}, [env, selectedRuns, isBulkReenqueuing, selection, onReload]);
const toggleSortOrder = () => {
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
};
// Only for local env and while we don't already have data,
// we periodically refresh the data to check for new runs.
// This is both to improve UX slightly, while also ensuring that
// we react to a workflow data directory being created after the first run.
useEffect(() => {
if (isLocalAndHasMissingData) {
const interval = setInterval(() => {
onRefresh();
}, 5000);
return () => clearInterval(interval);
}
}, [isLocalAndHasMissingData, onRefresh]);
// Refresh when tab regains focus after a delay, to prevent stale UI.
// TODO: We should generally move to using SWR or similar for _all_ API calls here.
// TODO: Further future, remove the refresh button entirely, and do live in-place refreshing
// once all world backends support live pagination of existing views.
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && lastRefreshTime) {
const timeSinceLastRefresh = Date.now() - lastRefreshTime.getTime();
if (timeSinceLastRefresh >= 10000) {
onReload();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [lastRefreshTime, onReload]);
const localDirText = (
<code className="font-mono">{localShortName || 'current directory'}</code>
);
return (
<div>
<FilterControls
workflowNameFilter={workflowNameFilter}
status={status}
seenWorkflowNames={seenWorkflowNames}
sortOrder={sortOrder}
loading={loading}
statusFilterRequiresWorkflowNameFilter={
statusFilterRequiresWorkflowNameFilter
}
onWorkflowChange={handleWorkflowFilter}
onStatusChange={handleStatusFilter}
onSortToggle={toggleSortOrder}
onRefresh={onReload}
lastRefreshTime={lastRefreshTime}
/>
<Card className="overflow-hidden mt-4 bg-background">
<CardContent className="p-0 max-h-[calc(100vh-280px)] overflow-auto">
<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"
disabled={!runs.length}
/>
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Workflow
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Run ID
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Status
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Started
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
Completed
</TableHead>
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10 w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{error ? (
<TableRow>
<TableCell colSpan={7} className="h-[400px]">
<div className="flex items-center justify-center h-full">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading runs</AlertTitle>
<AlertDescription>
{getErrorMessage(error)}
</AlertDescription>
</Alert>
</div>
</TableCell>
</TableRow>
) : loading && !hasLoadedOnce ? (
<TableRow>
<TableCell colSpan={7} className="h-[400px]">
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</TableCell>
</TableRow>
) : !data.data || data.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-[400px]">
<div className="text-sm text-center text-muted-foreground flex flex-col items-center justify-center gap-3 h-full">
<span className="text-sm">
No workflow runs found
{isLocalAndHasMissingData ? (
<> in {localDirText}</>
) : (
''
)}
.
</span>
{isLocalAndHasMissingData && (
<span className="text-sm flex items-center gap-2">
This view will update once you run a workflow.
</span>
)}
</div>
</TableCell>
</TableRow>
) : (
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 || '?'}
</CopyableText>
</TableCell>
<TableCell className="font-mono text-xs py-2">
<CopyableText text={run.runId} overlay>
{run.runId}
</CopyableText>
</TableCell>
<TableCell className="py-2">
<StatusBadge
status={run.status}
context={run}
durationMs={
run.startedAt
? (run.completedAt
? new Date(run.completedAt).getTime()
: Date.now()) -
new Date(run.startedAt).getTime()
: undefined
}
/>
</TableCell>
<TableCell className="py-2 text-muted-foreground text-xs">
{run.startedAt ? (
<RelativeTime date={run.startedAt} />
) : (
'-'
)}
</TableCell>
<TableCell className="py-2 text-muted-foreground text-xs">
{run.completedAt ? (
<RelativeTime date={run.completedAt} />
) : (
'-'
)}
</TableCell>
<TableCell className="py-2">
<LazyDropdownMenu
env={env}
runId={run.runId}
runStatus={run.status}
onSuccess={onReload}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">{pageInfo}</div>
<div className="flex gap-2 items-center">
<Button
variant="outline"
size="sm"
onClick={previousPage}
disabled={!hasPreviousPage}
>
<ChevronLeft />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={nextPage}
disabled={!hasNextPage}
>
Next
<ChevronRight />
</Button>
</div>
</div>
<SelectionBar
selectionCount={selection.selectionCount}
onClearSelection={selection.clearSelection}
itemLabel="runs"
actions={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
onClick={handleBulkReenqueue}
disabled={isBulkReenqueuing || selectedRuns.length === 0}
>
{isBulkReenqueuing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Zap className="h-4 w-4 mr-1" />
)}
{isBulkReenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'}
</Button>
</TooltipTrigger>
<TooltipContent>
Re-enqueue the workflow orchestration layer for selected runs.
Useful if workflows appear stuck.
</TooltipContent>
</Tooltip>
{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>
);
}