+
+ ({
+ id: each.id,
+ title: each.label ?? each.id,
+ })),
+ },
+ {
+ id: 'halt',
+ title: 'Ends the run when it fails',
+ rows: [
+ {
+ id: 'halts',
+ title: 'Fetch, Classify, Merge',
+ pinnable: false,
+ },
+ ],
+ },
+ ]}
+ previewId={preview}
+ pinnedId={pinned}
+ onPreview={setPreview}
+ onPin={setPinned}
+ />
+
+
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/flow/workflow-canvas.tsx b/packages/ui/src/components/flow/workflow-canvas.tsx
new file mode 100644
index 0000000000..afa2d2445c
--- /dev/null
+++ b/packages/ui/src/components/flow/workflow-canvas.tsx
@@ -0,0 +1,1107 @@
+'use client';
+
+import {
+ ReactFlowProvider,
+ useReactFlow,
+ type Edge,
+ type Node,
+} from '@xyflow/react';
+import { ListOrdered, Workflow } from 'lucide-react';
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type FocusEvent,
+ type KeyboardEvent,
+ type ReactNode,
+ type TouchEvent,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useMediaQuery } from '../../hooks/use-media-query';
+import { usePrefersReducedMotion } from '../../hooks/use-prefers-reduced-motion';
+import { useSwapFade } from '../../hooks/use-swap-fade';
+import { useT } from '../../i18n/client';
+import { cn } from '../../lib/cn';
+import { Alert } from '../feedback/alert';
+import type { IssueCounts } from '../feedback/issue-summary';
+import { SkeletonBox } from '../feedback/skeleton';
+import { Skeletonize } from '../feedback/skeleton-context';
+import { Button } from '../primitives/button';
+import { describeFlowGraph, flowListFormat } from './describe';
+import {
+ easeOutQuint,
+ FLOW_TOUCH_TARGET,
+ FLOW_VIEWPORT_DURATION,
+ FlowCanvas,
+ type FlowFitPolicy,
+} from './flow-canvas';
+import { FlowLegend, type FlowLegendEntry } from './flow-legend';
+import { FlowStepList } from './flow-step-list';
+import {
+ FLOW_NAVIGATION_KEYS,
+ flowNeighbour,
+ flowTabStop,
+} from './interaction/navigation';
+import { useFlowLayout } from './layout/use-flow-layout';
+import {
+ FLOW_DURATION,
+ FLOW_MOVE_TRANSITION,
+ FLOW_RING_TOTAL,
+} from './motion/flow-motion';
+import {
+ useLayoutTransition,
+ type FlowPicture,
+ type FlowTransitionPhase,
+ type FlowTransitionPlan,
+} from './motion/use-layout-transition';
+import {
+ highlightForBranch,
+ highlightForIncident,
+ sameFlowHighlight,
+ type FlowHighlight,
+ type FlowPath,
+} from './paths/highlight';
+import { flowStateAt, flowStateFromOverlay } from './playback/derive-state';
+import type { FlowPlayback, FlowRunOverlay } from './playback/types';
+import { FlowEdgeMarkers } from './render/edge-markers';
+import { FlowEntryNodeView } from './render/entry-node';
+import { FlowExitNodeView } from './render/exit-node';
+import {
+ FlowRenderProvider,
+ type FlowRenderContextValue,
+} from './render/flow-render-context';
+import { FlowGateNodeView } from './render/gate-node';
+import { FlowGroupFrameView, type FlowFrameData } from './render/group-frame';
+import { flowFrameCounters, flowLooks, looksSignature } from './render/looks';
+import { FlowPulseLayer } from './render/pulse-layer';
+import {
+ FlowRoutedEdgeView,
+ type FlowRoutedEdgeData,
+} from './render/routed-edge';
+import { FlowStepNodeView } from './render/step-node';
+import type { FlowEdge, FlowGraph, FlowLayout, FlowRect } from './types';
+import { validateFlowGraph } from './validate-graph';
+
+export type FlowView = 'chart' | 'list';
+
+export interface WorkflowCanvasProps {
+ graph: FlowGraph;
+ 'aria-label': string;
+ /** What the graph is a picture of; a change is another picture (laid
+ * out afresh, swapped in with a fade), the same key with a changed graph
+ * a live relayout (it glides to its new layout). */
+ layoutKey: string;
+ selectedId?: string | null;
+ onSelect?: (id: string | null) => void;
+ /** Bring this node into view, with the least pan. */
+ revealId?: string | null;
+ /** Problem counts by node id (Start, End and gates included). */
+ issues?: ReadonlyMap