>) => {
}
const nodeContent = (
- // biome-ignore lint/a11y/noStaticElementInteractions: canvas node interaction
({
+ mockUpdateNode: vi.fn(),
+ mockGetNode: vi.fn(),
+ mockNodeState: { current: { height: undefined as number | undefined } },
+ mockHandleConfigs: { current: undefined as unknown },
+ mockManifest: {
+ current: {
+ display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' },
+ handleConfiguration: [],
+ } as Record
,
+ },
+ mockOverrideConfig: { current: {} as Record },
+ mockMode: { current: 'design' as string },
+ // biome-ignore lint/suspicious/noExplicitAny: captures NodeLabel props for assertions
+ mockNodeLabel: vi.fn() as any,
+ mockSelectionState: { current: { multipleNodesSelected: false } },
+}));
+
+mockGetNode.mockImplementation(() => mockNodeState.current);
+mockUpdateNode.mockImplementation((_id: string, patch: { height?: number }) => {
+ if (patch?.height !== undefined) {
+ mockNodeState.current = { ...mockNodeState.current, height: patch.height };
+ }
+});
+
+vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => ({
+ ...(await importOriginal()),
+ // Stub Handle so the bar can render without a React Flow zustand provider.
+ Handle: () => null,
+ useStore: () => false,
+ useUpdateNodeInternals: () => vi.fn(),
+ useReactFlow: () => ({
+ updateNodeData: vi.fn(),
+ updateNode: mockUpdateNode,
+ getNode: mockGetNode,
+ }),
+}));
+
+vi.mock('../../hooks', () => ({
+ useNodeExecutionState: () => undefined,
+ useElementValidationStatus: () => undefined,
+}));
+vi.mock('../../core', () => ({
+ useNodeTypeRegistry: () => ({ getManifest: () => mockManifest.current }),
+}));
+vi.mock('../BaseCanvas/BaseCanvasModeProvider', () => ({
+ useBaseCanvasMode: () => ({ mode: mockMode.current }),
+}));
+vi.mock('../BaseCanvas/ConnectedHandlesContext', () => ({ useConnectedHandles: () => new Set() }));
+vi.mock('../BaseCanvas/SelectionStateContext', () => ({
+ useSelectionState: () => mockSelectionState.current,
+}));
+vi.mock('../BaseCanvas/CanvasThemeContext', () => ({
+ useCanvasTheme: () => ({ isDarkMode: false }),
+}));
+vi.mock('../Toolbar', () => ({ NodeToolbar: () => null }));
+vi.mock('../ButtonHandle/useButtonHandles', () => ({ useButtonHandles: () => null }));
+vi.mock('../ButtonHandle/SmartHandle', () => ({
+ SmartHandle: () => null,
+ SmartHandleProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+vi.mock('./BaseNodeConfigContext', () => ({
+ useBaseNodeOverrideConfig: () => ({
+ handleConfigurations: mockHandleConfigs.current,
+ ...mockOverrideConfig.current,
+ }),
+}));
+// A resolved trailing status indicator, so the ActionNeeded precedence is testable.
+vi.mock('../../utils/adornment-resolver', () => ({
+ resolveAdornments: () => ({ topRight: }),
+}));
+vi.mock('../../utils/toolbar-resolver', () => ({ resolveToolbar: () => undefined }));
+vi.mock('../../../i18n', () => ({
+ useSafeLingui: () => ({
+ _: (d: string | { message?: string; id: string }) =>
+ typeof d === 'string' ? d : (d.message ?? d.id),
+ }),
+}));
+vi.mock('@uipath/apollo-wind', () => ({
+ Skeleton: (props: Record) =>
,
+ Button: ({ children, ...props }: Record & { children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ disabled,
+ }: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ disabled?: boolean;
+ }) => (
+ onSelect?.()}>
+ {children}
+
+ ),
+ DropdownMenuSeparator: () => ,
+ cn: (...args: unknown[]) =>
+ args
+ .flat(Infinity)
+ .filter((v): v is string => typeof v === 'string' && v.length > 0)
+ .join(' '),
+}));
+vi.mock('../../utils/icon-registry', () => ({
+ getIcon: () => () => Icon
,
+ CanvasIcon: ({ icon }: { icon: string }) => ,
+}));
+vi.mock('../../utils/manifest-resolver', () => ({
+ resolveDisplay: (display: Record | undefined) => ({
+ label: 'HTTP Request',
+ subLabel: 'GET /orders',
+ shape: 'rectangle',
+ icon: 'globe',
+ iconBackground: '#611f69',
+ color: '#ffffff',
+ ...display,
+ }),
+ resolveHandles: () => [],
+}));
+vi.mock('./NodeLabel', () => ({
+ NodeLabel: (props: { label?: string }) => {
+ mockNodeLabel(props);
+ return {props.label}
;
+ },
+}));
+vi.mock('./BaseNodeInnerShape', () => ({
+ BaseInnerShape: ({
+ color,
+ background,
+ children,
+ }: {
+ color?: string;
+ background?: string;
+ children: React.ReactNode;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+import { SEQ_HANDLE_LEFT_OFFSET } from '../../constants';
+import { BaseNode } from './BaseNode';
+import { INVISIBLE_HANDLE_STYLE } from './BaseNodeBar';
+import { BaseNodeBarNode } from './BaseNodeBarNode';
+
+const defaultProps: NodeProps> = {
+ id: 'step-1',
+ type: 'uipath.http',
+ data: {},
+ selected: false,
+ dragging: false,
+ draggable: true,
+ zIndex: 0,
+ isConnectable: true,
+ positionAbsoluteX: 0,
+ positionAbsoluteY: 0,
+ selectable: true,
+ deletable: true,
+};
+
+describe('BaseNodeBar (sequential bar variant)', () => {
+ afterEach(() => {
+ mockUpdateNode.mockClear();
+ mockNodeState.current = { height: undefined };
+ mockHandleConfigs.current = undefined;
+ mockOverrideConfig.current = {};
+ mockMode.current = 'design';
+ mockNodeLabel.mockClear();
+ mockSelectionState.current = { multipleNodesSelected: false };
+ mockManifest.current = {
+ display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe' },
+ handleConfiguration: [],
+ };
+ });
+
+ // The core visual-drift contract: the same node resolves to the same display
+ // (label / icon / color source) whether it renders as a card or a bar.
+ describe('display parity with the card variant', () => {
+ it('renders the identical resolved label and icon', () => {
+ const { unmount } = render( );
+ const cardLabel = screen.getByTestId('node-label').textContent;
+ expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0);
+ unmount();
+
+ render( );
+ expect(screen.getByTestId('node-label').textContent).toBe(cardLabel);
+ expect(screen.getAllByTestId('node-icon').length).toBeGreaterThan(0);
+ });
+
+ it('feeds the icon shape from the same color source', () => {
+ const { unmount } = render( );
+ const cardShape = screen.getByTestId('inner-shape');
+ const cardBg = cardShape.getAttribute('data-background');
+ const cardColor = cardShape.getAttribute('data-color');
+ unmount();
+
+ render( );
+ const barShape = screen.getByTestId('inner-shape');
+ expect(barShape.getAttribute('data-background')).toBe(cardBg);
+ expect(barShape.getAttribute('data-color')).toBe(cardColor);
+ expect(cardBg).toBe('#611f69');
+ });
+ });
+
+ describe('bar geometry', () => {
+ it('renders at the fixed 896x56 bar size regardless of handle count', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar.style.getPropertyValue('--node-w')).toBe('896px');
+ expect(bar.style.getPropertyValue('--node-h')).toBe('56px');
+ });
+
+ it('writes the fixed bar height (56) back to node.height', () => {
+ render( );
+ expect(mockUpdateNode).toHaveBeenCalledWith('step-1', { height: 56 });
+ });
+ });
+
+ it('paints the left accent strip from the resolved icon background', () => {
+ render( );
+ const accent = screen.getByTestId('sequential-bar-accent');
+ // Rendered as an inset box-shadow so the stripe follows the bar's rounded
+ // corners rather than a square-cornered strip.
+ expect(accent.style.boxShadow).toContain('#611f69');
+ });
+
+ it('keeps inline rename enabled in design mode (readonly=false, onChange wired)', () => {
+ render( );
+ const labelProps = mockNodeLabel.mock.calls.at(-1)?.[0];
+ expect(labelProps.readonly).toBe(false);
+ expect(typeof labelProps.onChange).toBe('function');
+ expect(labelProps.shape).toBe('rectangle');
+ });
+
+ it('disables rename outside design mode', () => {
+ mockMode.current = 'view';
+ render( );
+ expect(mockNodeLabel.mock.calls.at(-1)?.[0].readonly).toBe(true);
+ });
+
+ describe('trailing group', () => {
+ it('renders the toolbar actions as a kebab when selected', () => {
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }],
+ },
+ };
+ render( );
+ expect(screen.getByLabelText('More options')).toBeInTheDocument();
+ });
+
+ it('fires the toolbar action with the node id when the item is selected', () => {
+ const onAction = vi.fn();
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [{ id: 'delete', icon: , label: 'Delete', onAction }],
+ },
+ };
+ render( );
+ fireEvent.click(screen.getByText('Delete'));
+ expect(onAction).toHaveBeenCalledWith('step-1');
+ });
+
+ it('resolves a string toolbar icon through the icon registry instead of rendering it as literal text', () => {
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [{ id: 'delete', icon: 'trash', label: 'Delete', onAction: vi.fn() }],
+ },
+ };
+ render( );
+ expect(screen.getByTestId('canvas-icon-trash')).toBeInTheDocument();
+ expect(screen.queryByText('trash')).not.toBeInTheDocument();
+ });
+
+ it('still renders a custom React node icon as-is (no double resolution)', () => {
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [
+ {
+ id: 'delete',
+ icon: ,
+ label: 'Delete',
+ onAction: vi.fn(),
+ },
+ ],
+ },
+ };
+ render( );
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
+ });
+
+ it('hides the kebab when multiple nodes are selected, mirroring the card toolbar gating', () => {
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }],
+ },
+ };
+ mockSelectionState.current = { multipleNodesSelected: true };
+ render( );
+ expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
+ });
+
+ it('shows the status indicator when not action-needed', () => {
+ render( );
+ expect(screen.getByTestId('status-indicator')).toBeInTheDocument();
+ expect(screen.queryByText('Action needed')).not.toBeInTheDocument();
+ });
+
+ it('gives the Action needed pill precedence over the status indicator', () => {
+ const onActionNeeded = vi.fn();
+ mockOverrideConfig.current = { executionStatusOverride: 'ActionNeeded', onActionNeeded };
+ render( );
+
+ expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument();
+ const pill = screen.getByText('Action needed');
+ fireEvent.click(pill);
+ expect(onActionNeeded).toHaveBeenCalledWith('step-1');
+ });
+ });
+
+ describe('extraMenuItems passthrough', () => {
+ it('shows the kebab from extraMenuItems alone, with no toolbar configured', () => {
+ render(
+
+ );
+ expect(screen.getByLabelText('More options')).toBeInTheDocument();
+ expect(screen.getByText('Move up')).toBeInTheDocument();
+ });
+
+ it('appends extraMenuItems after the toolbar actions with a divider between the two groups', () => {
+ mockOverrideConfig.current = {
+ toolbarConfig: {
+ actions: [{ id: 'delete', icon: , label: 'Delete', onAction: vi.fn() }],
+ },
+ };
+ const { container } = render(
+
+ );
+ expect(screen.getByText('Delete')).toBeInTheDocument();
+ expect(screen.getByText('Move up')).toBeInTheDocument();
+ // DropdownMenuSeparator is mocked to (see the @uipath/apollo-wind mock above).
+ expect(container.querySelectorAll('hr')).toHaveLength(1);
+ });
+
+ it('fires an extraMenuItems action on click', () => {
+ const onClick = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Move up'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders a disabled extraMenuItems action as disabled', () => {
+ render(
+
+ );
+ expect(screen.getByText('Move up').closest('button')).toBeDisabled();
+ });
+
+ it('renders no kebab at all when there is neither a toolbar nor extraMenuItems', () => {
+ render( );
+ expect(screen.queryByLabelText('More options')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('display parity: background and shadow', () => {
+ it('applies display.background as an inline background style, like BaseContainer', () => {
+ mockManifest.current = {
+ display: {
+ label: 'HTTP Request',
+ shape: 'rectangle',
+ icon: 'globe',
+ background: 'linear-gradient(90deg, #611f69, #0ea5e9)',
+ },
+ handleConfiguration: [],
+ };
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar.style.background).toContain('linear-gradient');
+ });
+
+ it('renders the rest shadow class by default (display.shadow unset)', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar.className).toContain('shadow-(--canvas-node-shadow-rest)');
+ });
+
+ it('omits the rest shadow class when display.shadow is false, like BaseContainer', () => {
+ mockManifest.current = {
+ display: { label: 'HTTP Request', shape: 'rectangle', icon: 'globe', shadow: false },
+ handleConfiguration: [],
+ };
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar.className).not.toContain('shadow-(--canvas-node-shadow-rest)');
+ });
+ });
+
+ describe('loading accessibility', () => {
+ it('sets aria-busy on the bar shell while loading, matching BaseContainer', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar).toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('omits aria-busy when not loading', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar).not.toHaveAttribute('aria-busy');
+ });
+ });
+
+ // Connectors anchor to the bar's bottom-left / top-left
+ // region instead of its horizontal center. `Handle` itself is stubbed to
+ // `null` above (BaseNode's Handle-rendering internals aren't under test
+ // here), so this asserts directly against the shared style object BOTH the
+ // top target and bottom source handles use -- the actual source of truth
+ // xyflow reads to position the real handle DOM nodes in production.
+ describe('handle anchoring', () => {
+ it('anchors the invisible top/bottom handles at SEQ_HANDLE_LEFT_OFFSET from the bar left edge, not the horizontal center', () => {
+ expect(INVISIBLE_HANDLE_STYLE.left).toBe(SEQ_HANDLE_LEFT_OFFSET);
+ });
+ });
+
+ // A collapsed collapsible sequential row renders the same
+ // decorative "stacked" treatment BaseContainer applies to drillable/collapsed
+ // cards (a layered bar peeking out behind), driven by a `stacked` prop
+ // (threaded via context by SequentialStepNode, never node.data) rather than
+ // any state BaseNodeBar derives itself.
+ describe('collapsed stacked treatment', () => {
+ it('renders no stacked-layer classes or data-stacked attribute by default', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar).not.toHaveAttribute('data-stacked');
+ expect(bar.className).not.toContain('before:border-brand');
+ });
+
+ it('renders the stacked-layer classes and data-stacked when stacked=true', () => {
+ render( );
+ const bar = screen.getByTestId('sequential-bar');
+ expect(bar).toHaveAttribute('data-stacked', 'true');
+ expect(bar.className).toContain('before:border-brand');
+ expect(bar.className).toContain('before:-z-10');
+ expect(bar.className).toContain('before:translate-y-[6px]');
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx
new file mode 100644
index 000000000..490747d7b
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBar.tsx
@@ -0,0 +1,390 @@
+import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { cn } from '@uipath/apollo-wind';
+import { memo, useMemo, useState } from 'react';
+import { useSafeLingui } from '../../../i18n';
+import { SEQ_BAR_HEIGHT, SEQ_BAR_WIDTH, SEQ_HANDLE_LEFT_OFFSET } from '../../constants';
+import type { SuggestionType } from '../../types';
+import type { ElementStatusValues } from '../../types/execution';
+import type { ValidationErrorSeverity } from '../../types/validation';
+import { CanvasIcon } from '../../utils/icon-registry';
+import type { NodeMenuItem } from '../NodeContextMenu';
+import { CanvasDropdownMenu } from '../shared/CanvasDropdownMenu';
+import type { NodeToolbarConfig } from '../Toolbar';
+import { getStatusBorder } from './BaseNodeContainer';
+import { BaseInnerShape } from './BaseNodeInnerShape';
+import { NodeLabel, type NodeLabelProps } from './NodeLabel';
+
+/**
+ * Bar geometry as CSS custom properties, shared by the manifest-backed step bar
+ * (BaseNodeBar) and the synthetic start / placeholder bars so all three render
+ * at an identical size. Fixed values (not the card's ratio math) because a
+ * compact row needs a constant icon box rather than one that scales with the
+ * container aspect ratio. Icon box shrunk to ~32px (from 40px) to stay
+ * balanced against the tightened `SEQ_BAR_HEIGHT`; the
+ * icon-to-box and radius-to-box ratios are unchanged from the original.
+ */
+export const SEQ_BAR_VARS = {
+ '--node-w': `${SEQ_BAR_WIDTH}px`,
+ '--node-h': `${SEQ_BAR_HEIGHT}px`,
+ '--node-radius': '12px',
+ '--inner-w': '32px',
+ '--inner-h': '32px',
+ '--inner-radius': '8px',
+ '--icon-size': '16px',
+} as React.CSSProperties;
+
+/** Returns the shared bar CSS variables with optional layout-owned dimensions. */
+export function getSeqBarVars(width = SEQ_BAR_WIDTH, height = SEQ_BAR_HEIGHT): React.CSSProperties {
+ return {
+ ...SEQ_BAR_VARS,
+ '--node-w': `${width}px`,
+ '--node-h': `${height}px`,
+ } as React.CSSProperties;
+}
+
+/**
+ * Static base classes for the bar shell. Mirrors BaseContainer's channel
+ * discipline (surface + border) in a horizontal layout. The rest/hover/lifted
+ * shadow classes are layered on per instance (gated by the `shadow` prop, like
+ * BaseContainer), along with status border, hover, and selection classes.
+ * Padding/gap tightened (`px-4`/`gap-3` -> `px-3`/`gap-2`) alongside the
+ * shorter `SEQ_BAR_HEIGHT` so the row reads balanced rather than cramped.
+ */
+export const SEQ_BAR_SHELL_CLASS =
+ 'relative flex flex-row items-center gap-2 px-3 bg-surface-overlay border border-border w-(--node-w) h-(--node-h) rounded-(--node-radius) outline-offset-0 transition-[box-shadow,border-color] duration-150';
+
+/**
+ * Handle ids used by every sequential bar. Rows expose one invisible top target
+ * and one invisible bottom source; connectors and the assembly wire
+ * edges between `source` of row N and `target` of row N+1.
+ */
+export const SEQUENTIAL_BAR_HANDLE_IDS = {
+ target: 'seq-target',
+ source: 'seq-source',
+ // Mid-left target used only by `branch-entry` connectors: a branch/container
+ // lane's first row is entered from its LEFT side at mid-height (not the top),
+ // so the elbow off the owner's spine reads as an indent. Left handles sit at
+ // the bar's left edge, vertically centered, with no left-offset.
+ branchTarget: 'seq-branch-target',
+} as const;
+
+/**
+ * Transparent 8px handle: sequential bars are not user-connectable in v1.
+ * `left` is pinned to `SEQ_HANDLE_LEFT_OFFSET` so the handle anchors to the
+ * bar's bottom-left / top-left region instead of xyflow's
+ * default horizontal center; the vertical placement and centering transform
+ * still come from xyflow's `.react-flow__handle-{top,bottom}` classes; this
+ * inline style only overrides `left`, not `transform`, so the handle's CENTER
+ * point lands exactly `SEQ_HANDLE_LEFT_OFFSET`px from the bar's left edge.
+ * Shared by the manifest-backed bar (below) and the synthetic start/placeholder
+ * bars (nodes/SequentialStartNode.tsx, nodes/SequentialPlaceholderNode.tsx) so
+ * every row -- real or synthetic -- anchors its connectors identically.
+ */
+export const INVISIBLE_HANDLE_STYLE: React.CSSProperties = {
+ background: 'transparent',
+ border: 'none',
+ width: 8,
+ height: 8,
+ left: SEQ_HANDLE_LEFT_OFFSET,
+};
+
+/**
+ * Decorative stacked layer for a collapsed sequential row, mirroring
+ * BaseContainer's `isStacked` recipe (components/BaseNode/BaseNodeContainer.tsx)
+ * adapted to the bar's horizontal shape: a `::before` pseudo-element the same
+ * size as the bar, offset down and pushed behind it (`-z-10`), so a thin arc of
+ * a second bar peeks out below -- signalling that collapsed content sits
+ * underneath. Kept as a local copy (not a shared export) so this file's owner
+ * doesn't need to also own BaseNodeContainer.tsx; keep the two class strings in
+ * sync if BaseContainer's recipe changes.
+ */
+const SEQ_BAR_STACKED_LAYER_CLASS =
+ 'before:content-[""] before:absolute before:inset-x-0 before:top-0 before:h-full before:rounded-[inherit] before:bg-surface-overlay before:border before:border-brand before:translate-y-[6px] before:-z-10 before:pointer-events-none';
+
+export interface BaseNodeBarProps {
+ nodeId: string;
+ width?: number;
+ height?: number;
+ mode?: 'design' | 'view' | 'readonly';
+ selected?: boolean;
+ dragging?: boolean;
+ disabled?: boolean;
+ label?: string;
+ subLabel?: NodeLabelProps['subLabel'];
+ labelTooltip?: string;
+ labelBackgroundColor?: string;
+ icon: React.ReactNode;
+ loading?: boolean;
+ /** Drives both the icon-box background and the left accent strip. */
+ iconBackground?: string;
+ iconColor?: string;
+ /** Same resolved display.background the card applies via BaseContainer. */
+ background?: string;
+ /** Same resolved display.shadow the card applies via BaseContainer. Defaults to true. */
+ shadow?: boolean;
+ executionStatus?: ElementStatusValues;
+ validationStatus?: ValidationErrorSeverity;
+ suggestionType?: SuggestionType;
+ /** The resolved trailing status/validation indicator (adornments.topRight). */
+ statusIndicator?: React.ReactNode;
+ /** Same resolved toolbar as the card; its actions feed the kebab menu (D3). */
+ toolbarConfig?: NodeToolbarConfig;
+ /** Hides the kebab during rubber-band multi-select, mirroring the card toolbar (BaseNode.tsx `hidden`). */
+ multipleNodesSelected?: boolean;
+ /**
+ * True when this row is a collapsed collapsible step. Driven by
+ * view-local collapsed state threaded through context (SequentialStepNode ->
+ * SequentialCollapsedRowsContext), never `node.data`, so the sequential
+ * clone's data stays reference-stable (D12). Renders the same decorative
+ * stacked-layer treatment BaseContainer uses for `isStacked` cards.
+ */
+ stacked?: boolean;
+ /**
+ * Extra kebab items appended after the resolved toolbar actions, with
+ * a divider between the two groups when both are non-empty. See
+ * `BaseNodeBarNodeProps.extraMenuItems`'s doc comment (BaseNodeBarNode.tsx) for the D3
+ * rationale (a direct prop, not a new `BaseNodeOverrideConfig` field).
+ */
+ extraMenuItems?: NodeMenuItem[];
+ /**
+ * Manifest source/target handle ids to expose as extra INVISIBLE anchor
+ * handles alongside the generic seq-source/seq-target. A bar collapses
+ * each side to one point visually, but the Add Node connection validator
+ * resolves a node's handle by id against its manifest, so the insert preview
+ * must be able to anchor on a real manifest handle id that the bar actually
+ * renders. Derived connectors keep using seq-source/seq-target.
+ */
+ manifestSourceHandleIds?: string[];
+ manifestTargetHandleIds?: string[];
+ onLabelChange?: (values: { label: string; subLabel: string }) => void;
+ onActionNeeded?: (nodeId: string) => void;
+}
+
+/**
+ * Horizontal "bar" rendering of a node for the Sequential Canvas view. Fed by
+ * the same resolved display / adornments / toolbar sources as BaseNode through
+ * useBaseNodePresentation, so a node looks and behaves identically in both
+ * views. Card geometry and interactions remain owned by BaseNode.
+ */
+function BaseNodeBarComponent({
+ nodeId,
+ width,
+ height,
+ mode,
+ selected,
+ dragging,
+ disabled,
+ label,
+ subLabel,
+ labelTooltip,
+ labelBackgroundColor,
+ icon,
+ loading,
+ iconBackground,
+ iconColor,
+ background,
+ shadow = true,
+ executionStatus,
+ validationStatus,
+ suggestionType,
+ statusIndicator,
+ toolbarConfig,
+ multipleNodesSelected,
+ stacked,
+ extraMenuItems,
+ manifestSourceHandleIds,
+ manifestTargetHandleIds,
+ onLabelChange,
+ onActionNeeded,
+}: BaseNodeBarProps) {
+ const { _ } = useSafeLingui();
+ const [isHovered, setIsHovered] = useState(false);
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ // Same priority as BaseContainer: suggestion > validation > execution. The
+ // status border stays the border channel; selection stays the outline channel.
+ const activeStatus = suggestionType ?? validationStatus ?? executionStatus;
+ const statusBorder = getStatusBorder(activeStatus);
+ const hasStatusBorder = statusBorder.length > 0;
+
+ const isActionNeeded = executionStatus === 'ActionNeeded';
+
+ // Kebab items are the resolved toolbar actions (design-mode delete/duplicate/
+ // etc.), flattened from the same NodeToolbarConfig the card toolbar consumes,
+ // with the extraMenuItems (Sequential Canvas move actions) appended after
+ // a divider when both groups are present.
+ const menuItems = useMemo(() => {
+ const toolbarItems: NodeMenuItem[] = !toolbarConfig
+ ? []
+ : [...toolbarConfig.actions, ...(toolbarConfig.overflowActions ?? [])].map(
+ (action) => {
+ if (!('onAction' in action)) {
+ return { type: 'divider' };
+ }
+ return {
+ id: action.id,
+ label: action.label ?? action.id,
+ icon:
+ typeof action.icon === 'string' ? (
+
+ ) : (
+ action.icon
+ ),
+ disabled: action.disabled,
+ onClick: () => action.onAction(nodeId),
+ };
+ }
+ );
+
+ if (!extraMenuItems || extraMenuItems.length === 0) return toolbarItems;
+ if (toolbarItems.length === 0) return extraMenuItems;
+ return [...toolbarItems, { type: 'divider' }, ...extraMenuItems];
+ }, [toolbarConfig, nodeId, extraMenuItems]);
+
+ const showKebab =
+ menuItems.length > 0 && !multipleNodesSelected && (isHovered || selected || menuOpen);
+
+ const className = cn(
+ SEQ_BAR_SHELL_CLASS,
+ 'cursor-pointer',
+ shadow && 'shadow-(--canvas-node-shadow-rest)',
+ statusBorder,
+ shadow && isHovered && 'shadow-(--canvas-node-shadow-hover)',
+ isHovered && !hasStatusBorder && 'border-border-hover',
+ selected && 'outline outline-2 outline-foreground-accent-muted',
+ disabled && 'opacity-50 cursor-not-allowed',
+ dragging && cn('cursor-grabbing', shadow && 'shadow-(--canvas-node-shadow-lifted)'),
+ stacked && SEQ_BAR_STACKED_LAYER_CLASS
+ );
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+ {/* Mid-left entry for branch-entry connectors (no left-offset: a Left
+ handle already anchors at the bar's left edge, vertically centered). */}
+
+ {manifestTargetHandleIds?.map((hid) =>
+ hid === SEQUENTIAL_BAR_HANDLE_IDS.target ? null : (
+
+ )
+ )}
+
+ {iconBackground && (
+ // Left accent stripe painted as an inset box-shadow on a bar-sized
+ // overlay so it follows the bar's rounded corners exactly. A plain
+ // `w-1` strip can't carry a matching corner radius (its 4px width clamps
+ // the curve), so its square corners poked past the rounded bar edge.
+ // `-inset-px` grows the overlay to the border-box edge (the overlay
+ // paints on top of the border), so the stripe sits flush against the
+ // selection outline instead of leaving the 1px border as a dark gap.
+
+ )}
+
+ {(icon || loading) && (
+
+
+ {loading ? null : icon}
+
+
+ )}
+
+
+
+
+ {isActionNeeded ? (
+ {
+ e.stopPropagation();
+ onActionNeeded?.(nodeId);
+ }}
+ >
+
+
+ {_({ id: 'sequential-canvas.action-needed', message: 'Action needed' })}
+
+
+ ) : (
+ statusIndicator
+ )}
+ {showKebab && (
+ item.onClick()}
+ triggerAriaLabel={_({ id: 'sequential-canvas.more-options', message: 'More options' })}
+ />
+ )}
+
+
+
+ {manifestSourceHandleIds?.map((hid) =>
+ hid === SEQUENTIAL_BAR_HANDLE_IDS.source ? null : (
+
+ )
+ )}
+
+ );
+}
+
+export const BaseNodeBar = memo(BaseNodeBarComponent);
diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx
new file mode 100644
index 000000000..453d535d8
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBarNode.tsx
@@ -0,0 +1,122 @@
+import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useReactFlow, useUpdateNodeInternals } from '@uipath/apollo-react/canvas/xyflow/react';
+import { memo, useEffect, useMemo } from 'react';
+import {
+ DEFAULT_NODE_SIZE,
+ DEFAULT_RECTANGLE_NODE_WIDTH,
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+} from '../../constants';
+import { areNodePropsEqualIgnoringPosition } from '../../utils/nodePropsEqual';
+import type { NodeMenuItem } from '../NodeContextMenu';
+import type { BaseNodeData } from './BaseNode.types';
+import { BaseNodeBar } from './BaseNodeBar';
+import { MissingManifestNode } from './BaseNodeMissingManifest';
+import { useBaseNodePresentation } from './useBaseNodePresentation';
+
+export type BaseNodeBarNodeProps = NodeProps> & {
+ stacked?: boolean;
+ extraMenuItems?: NodeMenuItem[];
+};
+
+function BaseNodeBarNodeComponent({
+ id,
+ data,
+ type,
+ selected,
+ dragging,
+ width,
+ height,
+ stacked,
+ extraMenuItems,
+}: BaseNodeBarNodeProps) {
+ const presentation = useBaseNodePresentation({
+ id,
+ data,
+ type,
+ selected,
+ dragging,
+ });
+ const { updateNode, getNode } = useReactFlow();
+ const updateNodeInternals = useUpdateNodeInternals();
+ const barHeight = height ?? SEQ_BAR_HEIGHT;
+ const barWidth =
+ width && width !== DEFAULT_NODE_SIZE && width !== DEFAULT_RECTANGLE_NODE_WIDTH
+ ? width
+ : SEQ_BAR_WIDTH;
+ const manifestHandleIds = useMemo(() => {
+ const sources: string[] = [];
+ const targets: string[] = [];
+ for (const group of presentation.handleConfigurations) {
+ for (const handle of group.handles) {
+ if (handle.type === 'source') sources.push(handle.id);
+ else if (handle.type === 'target') targets.push(handle.id);
+ }
+ }
+ return { sources, targets };
+ }, [presentation.handleConfigurations]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: handle configuration changes require XYFlow to recalculate the bar's invisible anchors.
+ useEffect(() => {
+ if (getNode(id)?.height !== barHeight) {
+ updateNode(id, { height: barHeight });
+ }
+ updateNodeInternals(id);
+ }, [barHeight, id, presentation.handleConfigurations, getNode, updateNode, updateNodeInternals]);
+
+ if (!presentation.manifest) {
+ return (
+
+ );
+ }
+
+ const {
+ disabled,
+ labelBackgroundColor,
+ labelTooltip,
+ onActionNeeded,
+ subLabelComponent,
+ suggestionType,
+ } = presentation.overrideConfig;
+
+ return (
+
+ );
+}
+
+export const BaseNodeBarNode = memo(BaseNodeBarNodeComponent, areNodePropsEqualIgnoringPosition);
diff --git a/packages/apollo-react/src/canvas/components/BaseNode/index.ts b/packages/apollo-react/src/canvas/components/BaseNode/index.ts
index 76069fd0b..7e37289b7 100644
--- a/packages/apollo-react/src/canvas/components/BaseNode/index.ts
+++ b/packages/apollo-react/src/canvas/components/BaseNode/index.ts
@@ -1,4 +1,5 @@
export * from './BaseNode';
export * from './BaseNode.types';
+export * from './BaseNodeBarNode';
export * from './BaseNodeConfigContext';
export * from './useNodeCollapse';
diff --git a/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx b/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx
new file mode 100644
index 000000000..8e866de16
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/BaseNode/useBaseNodePresentation.tsx
@@ -0,0 +1,159 @@
+import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useReactFlow, useStore } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useMemo } from 'react';
+import { useNodeTypeRegistry } from '../../core';
+import { useElementValidationStatus, useNodeExecutionState } from '../../hooks';
+import type { HandleGroupManifest } from '../../schema/node-definition';
+import { resolveAdornments } from '../../utils/adornment-resolver';
+import { getIcon } from '../../utils/icon-registry';
+import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver';
+import { selectIsConnecting } from '../../utils/NodeUtils';
+import { resolveToolbar } from '../../utils/toolbar-resolver';
+import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider';
+import { useCanvasTheme } from '../BaseCanvas/CanvasThemeContext';
+import { useSelectionState } from '../BaseCanvas/SelectionStateContext';
+import { InitialsBadge } from '../shared/InitialsBadge';
+import type { BaseNodeData, NodeAdornments, NodeStatusContext } from './BaseNode.types';
+import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext';
+
+type PresentationNodeProps = Pick<
+ NodeProps>,
+ 'data' | 'dragging' | 'id' | 'selected' | 'type'
+>;
+
+/**
+ * Manifest-backed presentation model shared by the card and sequential bar
+ * node renderers. View selection stays outside this hook: both renderers
+ * resolve the same display, icon, handles, toolbar, adornments, and statuses,
+ * then render their own geometry and interactions.
+ */
+export function useBaseNodePresentation({
+ type,
+ data,
+ selected,
+ id,
+ dragging,
+}: PresentationNodeProps) {
+ const overrideConfig = useBaseNodeOverrideConfig();
+ const {
+ executionStatusOverride,
+ handleConfigurations: handleConfigurationsOverride,
+ toolbarConfig: toolbarConfigOverride,
+ adornments: adornmentsOverride,
+ iconComponent,
+ } = overrideConfig;
+ const executionState = useNodeExecutionState(id);
+ const validationState = useElementValidationStatus(id);
+ const nodeTypeRegistry = useNodeTypeRegistry();
+ const { mode } = useBaseCanvasMode();
+ const isConnecting = useStore(selectIsConnecting);
+ const { multipleNodesSelected } = useSelectionState();
+ const { isDarkMode } = useCanvasTheme();
+ const { updateNodeData } = useReactFlow();
+
+ const manifest = useMemo(() => nodeTypeRegistry.getManifest(type), [type, nodeTypeRegistry]);
+ const statusContext: NodeStatusContext = useMemo(
+ () => ({
+ nodeId: id,
+ executionState: executionStatusOverride ?? executionState,
+ validationState,
+ isConnecting,
+ isSelected: selected,
+ isDragging: dragging,
+ mode,
+ }),
+ [
+ id,
+ executionStatusOverride,
+ executionState,
+ validationState,
+ isConnecting,
+ selected,
+ dragging,
+ mode,
+ ]
+ );
+ const executionStatus =
+ executionStatusOverride ??
+ (typeof executionState === 'string' ? executionState : executionState?.status);
+ const display = useMemo(
+ () => resolveDisplay(manifest?.display, { ...data, nodeId: id }),
+ [manifest, data, id]
+ );
+ const icon = useMemo(() => {
+ if (iconComponent !== undefined) return iconComponent;
+ if (display.icon) {
+ const IconComponent = getIcon(display.icon);
+ return IconComponent ? : null;
+ }
+ return ;
+ }, [iconComponent, display.icon, display.label]);
+ const handleConfigurations = useMemo((): HandleGroupManifest[] => {
+ if (handleConfigurationsOverride && Array.isArray(handleConfigurationsOverride)) {
+ return handleConfigurationsOverride;
+ }
+ const dataHandleConfigs = (data as Record)?.handleConfigurations as
+ | HandleGroupManifest[]
+ | undefined;
+ if (dataHandleConfigs && Array.isArray(dataHandleConfigs)) return dataHandleConfigs;
+ if (!manifest) return [];
+ return resolveHandles(manifest.handleConfiguration, { ...data, nodeId: id }).map((group) => ({
+ position: group.position,
+ handles: group.handles.map((handle) => ({
+ id: handle.id,
+ type: handle.type,
+ handleType: handle.handleType,
+ label: handle.label,
+ visible: handle.visible,
+ showButton: handle.showButton,
+ labelVisibility: handle.labelVisibility,
+ constraints: handle.constraints,
+ })),
+ visible: group.visible,
+ }));
+ }, [handleConfigurationsOverride, manifest, data, id]);
+ const toolbarConfig = useMemo(() => {
+ if (toolbarConfigOverride !== undefined) {
+ return toolbarConfigOverride === null ? undefined : toolbarConfigOverride;
+ }
+ return manifest ? resolveToolbar(manifest, statusContext) : undefined;
+ }, [toolbarConfigOverride, manifest, statusContext]);
+ const adornments: NodeAdornments = useMemo(
+ () => ({
+ ...resolveAdornments(statusContext),
+ ...(adornmentsOverride ?? {}),
+ }),
+ [adornmentsOverride, statusContext]
+ );
+ const onLabelChange = useCallback(
+ (values: { label: string; subLabel: string }) => {
+ const nextDisplay = { ...data.display };
+ for (const labelKey of Object.keys(values) as (keyof typeof values)[]) {
+ if (values[labelKey]) nextDisplay[labelKey] = values[labelKey];
+ else delete nextDisplay[labelKey];
+ }
+ updateNodeData(id, { display: nextDisplay });
+ },
+ [id, data.display, updateNodeData]
+ );
+ const iconBackground = isDarkMode
+ ? (display.iconBackgroundDark ?? display.iconBackground)
+ : display.iconBackground;
+
+ return {
+ adornments,
+ display,
+ executionStatus,
+ handleConfigurations,
+ icon,
+ iconBackground,
+ isConnecting,
+ manifest,
+ mode,
+ multipleNodesSelected,
+ onLabelChange,
+ overrideConfig,
+ toolbarConfig,
+ validationState,
+ };
+}
diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts
index 4c6b3c985..733c0978d 100644
--- a/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts
+++ b/packages/apollo-react/src/canvas/components/Edges/shared/hooks/useEdgeGeometry.ts
@@ -50,6 +50,13 @@ export type UseEdgeGeometryArgs = {
* arrow polygon would have filled.
*/
hideArrowHead?: boolean;
+ /**
+ * Corner radius (px) for the rounded (smooth-step) waypoint path. Defaults to
+ * `EDGE_CONSTANTS.BORDER_RADIUS`. `createRoundedPath` clamps it to half the
+ * shorter adjacent segment, so an over-large value degrades gracefully on
+ * short segments. Only affects `waypoint` routing.
+ */
+ borderRadius?: number;
};
export type EdgeGeometry = {
@@ -93,6 +100,7 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry {
autoRouted = false,
enableSegments = true,
hideArrowHead = false,
+ borderRadius = EDGE_CONSTANTS.BORDER_RADIUS,
} = args;
const arrowOffset = ARROW_OFFSETS[targetPosition];
@@ -132,8 +140,8 @@ export function useEdgeGeometry(args: UseEdgeGeometryArgs): EdgeGeometry {
);
const waypointPath = useMemo(
- () => createRoundedPath(pathPoints, EDGE_CONSTANTS.BORDER_RADIUS),
- [pathPoints]
+ () => createRoundedPath(pathPoints, borderRadius),
+ [pathPoints, borderRadius]
);
const handle = useEdgePath({
diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx
index 10bc89106..3a08ccce2 100644
--- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx
+++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/EdgeLabel.tsx
@@ -5,6 +5,34 @@ export type EdgeLabelProps = {
selected?: boolean;
};
+export type EdgeLabelContentProps = Pick & {
+ centered?: boolean;
+};
+
+/** Shared visual treatment for SVG and portal-positioned edge labels. */
+export function EdgeLabelContent({ text, selected, centered }: EdgeLabelContentProps) {
+ return (
+
+ {text}
+
+ );
+}
+
export function EdgeLabel({ x, y, text, selected }: EdgeLabelProps) {
return (
-
- {text}
-
+
);
}
diff --git a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts
index ed4d258c1..0837be3f3 100644
--- a/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts
+++ b/packages/apollo-react/src/canvas/components/Edges/shared/primitives/index.ts
@@ -1,7 +1,7 @@
export type { EdgeArrowProps } from './EdgeArrow';
export { EdgeArrow } from './EdgeArrow';
-export type { EdgeLabelProps } from './EdgeLabel';
-export { EdgeLabel } from './EdgeLabel';
+export type { EdgeLabelContentProps, EdgeLabelProps } from './EdgeLabel';
+export { EdgeLabel, EdgeLabelContent } from './EdgeLabel';
export type { EdgePathProps } from './EdgePath';
export { EdgePath } from './EdgePath';
export type { SegmentDragHandleProps } from './SegmentDragHandle';
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx
new file mode 100644
index 000000000..ba1563d7c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialAccessibleList.tsx
@@ -0,0 +1,117 @@
+import type { Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { memo, useCallback, useMemo, useRef } from 'react';
+import { useSafeLingui } from '../../../i18n';
+import type { SequentialKeyboardRow } from './useSequentialKeyboard';
+
+interface AccessibleRow extends SequentialKeyboardRow {
+ stepNumber?: number;
+}
+
+export interface SequentialAccessibleListProps {
+ rows: readonly AccessibleRow[];
+ nodes: readonly Node[];
+ selectedNodeId?: string;
+ onSelectNode: (nodeId: string) => void;
+ onToggleCollapse: (nodeId: string, collapsed: boolean) => void;
+}
+
+interface SequentialAccessibleRowProps {
+ row: AccessibleRow;
+ label: string;
+ selected: boolean;
+ toggleLabel?: string;
+ onSelectNode: (nodeId: string) => void;
+ onToggleCollapse: (nodeId: string, collapsed: boolean) => void;
+}
+
+const SequentialAccessibleRow = memo(function SequentialAccessibleRow({
+ row,
+ label,
+ selected,
+ toggleLabel,
+ onSelectNode,
+ onToggleCollapse,
+}: SequentialAccessibleRowProps) {
+ return (
+
+ onSelectNode(row.nodeId)}
+ >
+ {label}
+
+ {row.collapsible && (
+ onToggleCollapse(row.nodeId, !row.collapsed)}
+ >
+ {toggleLabel}
+
+ )}
+
+ );
+});
+
+/**
+ * Screen-reader fallback used when the visual canvas must virtualize a very
+ * large graph. The visual ReactFlow subtree is aria-hidden in that mode, while
+ * this focusable, DOM-ordered list keeps every step reachable and operable.
+ */
+export function SequentialAccessibleList({
+ rows,
+ nodes,
+ selectedNodeId,
+ onSelectNode,
+ onToggleCollapse,
+}: SequentialAccessibleListProps) {
+ const { _ } = useSafeLingui();
+ const labelsById = useMemo(
+ () => new Map(nodes.map((node) => [node.id, node.ariaLabel ?? node.id])),
+ [nodes]
+ );
+ const callbacksRef = useRef({ onSelectNode, onToggleCollapse });
+ callbacksRef.current = { onSelectNode, onToggleCollapse };
+ const selectNode = useCallback((nodeId: string) => callbacksRef.current.onSelectNode(nodeId), []);
+ const toggleCollapse = useCallback(
+ (nodeId: string, collapsed: boolean) =>
+ callbacksRef.current.onToggleCollapse(nodeId, collapsed),
+ []
+ );
+
+ return (
+
+ {rows.map((row) => {
+ const label = labelsById.get(row.nodeId) ?? row.nodeId;
+ const toggleLabel = !row.collapsible
+ ? undefined
+ : row.collapsed
+ ? _({
+ id: 'sequential-canvas.gutter.expand-step',
+ message: 'Expand step {stepNumber}',
+ values: { stepNumber: row.stepNumber },
+ })
+ : _({
+ id: 'sequential-canvas.gutter.collapse-step',
+ message: 'Collapse step {stepNumber}',
+ values: { stepNumber: row.stepNumber },
+ });
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts
new file mode 100644
index 000000000..b3f9e23ad
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.roundtrip.test.ts
@@ -0,0 +1,129 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { sequenceFingerprint } from '../../utils/sequential/fingerprint';
+import { makeWireframeFixture, WIREFRAME_NODE_IDS } from '../../utils/sequential/fixtures';
+import { insertAtSlot } from '../../utils/sequential/mutations';
+import { projectSequence } from '../../utils/sequential/projectSequence';
+import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert';
+import { synthesizePositionsForFlow } from './synthesizePositionsForFlow';
+import { deriveSequentialGraph } from './useSequentialGraph';
+
+/**
+ * Acceptance test: insert a node while in the sequential view
+ * (simulated via the pure insertAtSlot op, exactly what the change-forwarding
+ * produces from the live pipeline), place it on toggle-to-flow with
+ * synthesizePositionsForFlow, and assert:
+ * 1. no inserted node overlaps another node in its frame;
+ * 2. NO pre-existing node moved (returned by identity, positions intact);
+ * 3. toggling back yields an identical projection (fingerprint + structure).
+ */
+
+function applyChangeset(
+ nodes: Node[],
+ edges: Edge[],
+ changeset: ReturnType
+): { nodes: Node[]; edges: Edge[] } {
+ const removeNodes = new Set(changeset.removeNodeIds);
+ const removeEdges = new Set(changeset.removeEdgeIds);
+ return {
+ nodes: [...nodes.filter((node) => !removeNodes.has(node.id)), ...changeset.addNodes],
+ edges: [...edges.filter((edge) => !removeEdges.has(edge.id)), ...changeset.addEdges],
+ };
+}
+
+function boxesOverlap(a: Node, b: Node): boolean {
+ const aw = a.width ?? 96;
+ const ah = a.height ?? 96;
+ const bw = b.width ?? 96;
+ const bh = b.height ?? 96;
+ return (
+ a.position.x < b.position.x + bw &&
+ a.position.x + aw > b.position.x &&
+ a.position.y < b.position.y + bh &&
+ a.position.y + ah > b.position.y
+ );
+}
+
+function assertNoOverlapWithinFrames(nodes: Node[]): void {
+ const byFrame = new Map();
+ for (const node of nodes) {
+ const frame = node.parentId ?? '__root__';
+ (byFrame.get(frame) ?? byFrame.set(frame, []).get(frame)!).push(node);
+ }
+ for (const frameNodes of byFrame.values()) {
+ for (let i = 0; i < frameNodes.length; i++) {
+ for (let j = i + 1; j < frameNodes.length; j++) {
+ expect(boxesOverlap(frameNodes[i]!, frameNodes[j]!)).toBe(false);
+ }
+ }
+ }
+}
+
+function projectionSignature(nodes: Node[], edges: Edge[]) {
+ const projection = projectSequence(nodes, edges);
+ return {
+ rows: projection.rows.map((row) => ({
+ nodeId: row.nodeId,
+ depth: row.depth,
+ stepNumber: row.stepNumber,
+ })),
+ connectors: projection.connectors.map((connector) => ({
+ kind: connector.kind,
+ source: connector.sourceRowId,
+ target: connector.targetRowId,
+ })),
+ };
+}
+
+describe('sequential insert round-trip (D4)', () => {
+ it('inserts, places on toggle-to-flow without moving anything, and re-projects identically', () => {
+ const { nodes, edges } = makeWireframeFixture();
+
+ // 1. Derive the sequential view and grab the step slot between HTTP and JS.
+ const initial = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const slot = initial.projection!.slots.find(
+ (candidate) => candidate.graphEdgeId === 'e-http-js'
+ );
+ expect(slot).toBeDefined();
+
+ // 2. Simulate the pipeline insert: a new seqInserted node spliced into the slot.
+ const newNode: Node = {
+ id: 'inserted-1',
+ type: 'uipath.slack',
+ position: { x: 0, y: 0 },
+ data: { display: { label: 'Send Slack' }, [SEQ_INSERTED_FLAG]: true },
+ };
+ const changeset = insertAtSlot(initial.projection!, slot!, newNode);
+ const afterInsert = applyChangeset(nodes, edges, changeset);
+
+ // The insert is inline (HTTP -> inserted -> Javascript).
+ const insertedProjection = projectSequence(afterInsert.nodes, afterInsert.edges);
+ const insertedRow = insertedProjection.rows.find((row) => row.nodeId === 'inserted-1');
+ expect(insertedRow?.depth).toBe(0);
+
+ const fingerprintBefore = sequenceFingerprint(afterInsert.nodes, afterInsert.edges, new Set());
+ const signatureBefore = projectionSignature(afterInsert.nodes, afterInsert.edges);
+
+ // 3. Toggle to flow: synthesize a real position for the inserted node.
+ const flowNodes = synthesizePositionsForFlow(afterInsert.nodes, afterInsert.edges);
+
+ // No overlaps anywhere.
+ assertNoOverlapWithinFrames(flowNodes);
+
+ // No pre-existing node moved: each original node comes back by identity.
+ for (const id of Object.values(WIREFRAME_NODE_IDS)) {
+ const original = nodes.find((node) => node.id === id)!;
+ expect(flowNodes.find((node) => node.id === id)).toBe(original);
+ }
+
+ // The inserted node now has a real position and no sequential marker.
+ const placed = flowNodes.find((node) => node.id === 'inserted-1')!;
+ expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined();
+
+ // 4. Toggle back to sequential: identical projection (positions + the cleared
+ // flag are both ignored by the projection and the fingerprint).
+ const fingerprintAfter = sequenceFingerprint(flowNodes, afterInsert.edges, new Set());
+ expect(fingerprintAfter).toBe(fingerprintBefore);
+ expect(projectionSignature(flowNodes, afterInsert.edges)).toEqual(signatureBefore);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx
new file mode 100644
index 000000000..97041a5e9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.stories.tsx
@@ -0,0 +1,515 @@
+/**
+ * Sequential Canvas stories.
+ *
+ * These are the flagship stories for the feature: the wireframe reproduction, a
+ * generated performance graph, and execution status flowing into bars.
+ * SequentialCanvas is intentionally NOT exported from components/index.ts until
+ * GA (D13), so every import below is a direct source path.
+ */
+import type { Meta, StoryObj } from '@storybook/react';
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Switch, ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind';
+import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { SEQ_INDENT_PX } from '../../constants';
+import { StoryInfoPanel, withCanvasProviders } from '../../storybook-utils';
+import {
+ SequentialCanvasStoryHarness,
+ sequentialWireframeManifests,
+} from '../../storybook-utils/sequential';
+import { makeWireframeFixture } from '../../utils/sequential/fixtures';
+import { SequentialCanvas } from './SequentialCanvas';
+
+const meta: Meta = {
+ title: 'Components/Canvas/SequentialCanvas',
+ parameters: { layout: 'fullscreen' },
+ decorators: [withCanvasProviders()],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const SEQUENCE_DENSITY_PRESETS = [
+ {
+ id: 'compact',
+ label: 'Compact',
+ nodeWidth: 512,
+ nodeHeight: 40,
+ indentMultiplier: 1,
+ rowGap: 24,
+ },
+ {
+ id: 'balanced',
+ label: 'Balanced',
+ nodeWidth: 512,
+ nodeHeight: 48,
+ indentMultiplier: 1.25,
+ rowGap: 48,
+ },
+ {
+ id: 'spacious',
+ label: 'Spacious',
+ nodeWidth: 800,
+ nodeHeight: 64,
+ indentMultiplier: 2,
+ rowGap: 64,
+ },
+] as const;
+const BALANCED_DENSITY_PRESET = SEQUENCE_DENSITY_PRESETS[1];
+
+// ---------------------------------------------------------------------------
+// (a) Interactive Sequence: configurable sequential workflow example.
+// ---------------------------------------------------------------------------
+
+function InteractiveSequenceStory() {
+ const fixture = useMemo(() => makeWireframeFixture(), []);
+ const [isReadonly, setIsReadonly] = useState(false);
+ const [nodeWidth, setNodeWidth] = useState(BALANCED_DENSITY_PRESET.nodeWidth);
+ const [nodeHeight, setNodeHeight] = useState(BALANCED_DENSITY_PRESET.nodeHeight);
+ const [indentMultiplier, setIndentMultiplier] = useState(
+ BALANCED_DENSITY_PRESET.indentMultiplier
+ );
+ const [rowGap, setRowGap] = useState(BALANCED_DENSITY_PRESET.rowGap);
+ const sequenceLayoutOptions = useMemo(
+ () => ({
+ barWidth: nodeWidth,
+ barHeight: nodeHeight,
+ indent: Math.round((SEQ_INDENT_PX * indentMultiplier) / 16) * 16,
+ rowGap,
+ }),
+ [nodeWidth, nodeHeight, indentMultiplier, rowGap]
+ );
+ const activeDensityPreset =
+ SEQUENCE_DENSITY_PRESETS.find(
+ (preset) =>
+ preset.nodeWidth === nodeWidth &&
+ preset.nodeHeight === nodeHeight &&
+ preset.indentMultiplier === indentMultiplier &&
+ preset.rowGap === rowGap
+ )?.id ?? '';
+
+ const applyDensityPreset = (presetId: string) => {
+ const preset = SEQUENCE_DENSITY_PRESETS.find(({ id }) => id === presetId);
+ if (!preset) return;
+
+ setNodeWidth(preset.nodeWidth);
+ setNodeHeight(preset.nodeHeight);
+ setIndentMultiplier(preset.indentMultiplier);
+ setRowGap(preset.rowGap);
+ };
+
+ return (
+
+
console.log('Add trigger clicked')}
+ />
+
+
+
+
+ );
+}
+
+export const InteractiveSequence: Story = {
+ name: 'Interactive Sequence',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Reproduces the design concept: a Workflow start bar, HTTP Request, Javascript, a For Each container whose body is an If branching into Then: Javascript 1 and Else: HTTP Request 1, Send Message to User, and the terminal add-step control. Built from utils/sequential/fixtures.ts makeWireframeFixture, the same fixture the projection engine unit tests assert exact step numbers and connectors against.',
+ },
+ },
+ },
+ render: () => ,
+};
+
+// ---------------------------------------------------------------------------
+// (b) Performance: a generated ~150-node graph mixing branches and containers.
+// ---------------------------------------------------------------------------
+
+const PERF_TYPES = {
+ step: 'uipath.data.transform',
+ decision: 'uipath.control-flow.decision',
+ foreach: 'uipath.control-flow.foreach',
+} as const;
+
+const DEFAULT_PERF_UNIT_COUNT = 30;
+const MIN_PERF_UNIT_COUNT = 1;
+const MAX_PERF_UNIT_COUNT = 60;
+const PERF_GRAPH_REBUILD_DEBOUNCE_MS = 150;
+
+function perfNode(id: string, type: string, label: string, parentId?: string): Node {
+ return {
+ id,
+ type,
+ position: { x: 0, y: 0 },
+ ...(parentId ? { parentId } : {}),
+ data: { display: { label } },
+ };
+}
+
+function perfEdge(
+ id: string,
+ source: string,
+ sourceHandle: string,
+ target: string,
+ targetHandle: string,
+ label?: string
+): Edge {
+ return {
+ id,
+ source,
+ target,
+ sourceHandle,
+ targetHandle,
+ ...(label ? { data: { label } } : {}),
+ };
+}
+
+/**
+ * Generates a mixed branch/container graph of roughly `unitCount * 5 + 1`
+ * nodes: a spine of Decision -> (Then/Else) -> For Each[one child], repeated
+ * `unitCount` times, with each container child looping back into the
+ * container's `continue` handle. Used to stress-test projection, layout, and
+ * render cost at scale; D12's structural fingerprint memo is what keeps a
+ * data-only change (e.g. an inline rename) from re-running either.
+ */
+function createPerformanceFixture(unitCount: number): { nodes: Node[]; edges: Edge[] } {
+ const nodes: Node[] = [perfNode('perf-root', PERF_TYPES.step, 'Load batch')];
+ const edges: Edge[] = [];
+ let previousId = 'perf-root';
+
+ for (let i = 0; i < unitCount; i++) {
+ const decisionId = `perf-decision-${i}`;
+ const thenId = `perf-then-${i}`;
+ const elseId = `perf-else-${i}`;
+ const foreachId = `perf-foreach-${i}`;
+ const childId = `perf-child-${i}`;
+
+ nodes.push(
+ perfNode(decisionId, PERF_TYPES.decision, `Check batch ${i + 1}`),
+ perfNode(thenId, PERF_TYPES.step, `Transform ${i + 1}`),
+ perfNode(elseId, PERF_TYPES.step, `Fallback ${i + 1}`),
+ perfNode(foreachId, PERF_TYPES.foreach, `For each item ${i + 1}`),
+ perfNode(childId, PERF_TYPES.step, `Process item ${i + 1}`, foreachId)
+ );
+
+ edges.push(
+ perfEdge(`perf-e-${previousId}-${decisionId}`, previousId, 'output', decisionId, 'input'),
+ perfEdge(`perf-e-${decisionId}-${thenId}`, decisionId, 'true', thenId, 'input', 'Then'),
+ perfEdge(`perf-e-${decisionId}-${elseId}`, decisionId, 'false', elseId, 'input', 'Else'),
+ perfEdge(`perf-e-${thenId}-${foreachId}`, thenId, 'output', foreachId, 'input'),
+ perfEdge(`perf-e-${elseId}-${foreachId}`, elseId, 'output', foreachId, 'input'),
+ perfEdge(`perf-e-${foreachId}-${childId}`, foreachId, 'start', childId, 'input'),
+ perfEdge(`perf-e-${childId}-${foreachId}`, childId, 'output', foreachId, 'continue')
+ );
+
+ previousId = foreachId;
+ }
+
+ return { nodes, edges };
+}
+
+function preservePerformanceFixtureReferences(
+ current: { nodes: Node[]; edges: Edge[] },
+ next: { nodes: Node[]; edges: Edge[] }
+): { nodes: Node[]; edges: Edge[] } {
+ const currentNodesById = new Map(current.nodes.map((node) => [node.id, node]));
+ const currentEdgesById = new Map(current.edges.map((edge) => [edge.id, edge]));
+ return {
+ nodes: next.nodes.map((node) => currentNodesById.get(node.id) ?? node),
+ edges: next.edges.map((edge) => currentEdgesById.get(edge.id) ?? edge),
+ };
+}
+
+interface PerformanceControlsProps {
+ showDesignAffordances: boolean;
+ onShowDesignAffordancesChange: (checked: boolean) => void;
+ onUnitCountCommit: (unitCount: number) => void;
+}
+
+/**
+ * Keeps high-frequency slider state below SequentialCanvas. If this state lived
+ * in PerformanceStory, every pointer tick would reconcile the full xyflow tree
+ * even though graph rebuilding itself was debounced.
+ */
+function PerformanceControls({
+ showDesignAffordances,
+ onShowDesignAffordancesChange,
+ onUnitCountCommit,
+}: PerformanceControlsProps) {
+ const [unitCount, setUnitCount] = useState(DEFAULT_PERF_UNIT_COUNT);
+ const rebuildTimerRef = useRef | null>(null);
+ const targetNodeCount = unitCount * 5 + 1;
+
+ useEffect(
+ () => () => {
+ if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
+ },
+ []
+ );
+
+ const handleUnitCountChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const nextCount = Number.parseInt(event.target.value, 10);
+ setUnitCount(nextCount);
+
+ if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
+ rebuildTimerRef.current = setTimeout(() => {
+ onUnitCountCommit(nextCount);
+ rebuildTimerRef.current = null;
+ }, PERF_GRAPH_REBUILD_DEBOUNCE_MS);
+ },
+ [onUnitCountCommit]
+ );
+
+ return (
+
+
+ Design affordances
+
+
+
+ Nodes
+ {targetNodeCount}
+
+
+
+ );
+}
+
+function PerformanceStory() {
+ const [showDesignAffordances, setShowDesignAffordances] = useState(false);
+ const [collapsedStepIds, setCollapsedStepIds] = useState([]);
+ const [graph, setGraph] = useState(() => createPerformanceFixture(DEFAULT_PERF_UNIT_COUNT));
+
+ const handleUnitCountCommit = useCallback((nextCount: number) => {
+ const nextGraph = createPerformanceFixture(nextCount);
+ const nextNodeIds = new Set(nextGraph.nodes.map((node) => node.id));
+ startTransition(() => {
+ setGraph((current) => preservePerformanceFixtureReferences(current, nextGraph));
+ setCollapsedStepIds((current) => current.filter((id) => nextNodeIds.has(id)));
+ });
+ }, []);
+
+ const onNodesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setGraph((current) => ({
+ ...current,
+ nodes: applyNodeChanges(changes, current.nodes),
+ })),
+ []
+ );
+ const onEdgesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setGraph((current) => ({
+ ...current,
+ edges: applyEdgeChanges(changes, current.edges),
+ })),
+ []
+ );
+
+ return (
+
+
+
+ );
+}
+
+export const Performance: Story = {
+ name: 'Performance',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A generated graph mixing branches and containers, defaulting to about 150 nodes. Use this to profile pan and zoom under load, and to confirm projectSequence reuses its cached layout on data-only changes (D12).',
+ },
+ },
+ },
+ render: () => ,
+};
+
+// ---------------------------------------------------------------------------
+// (c) ExecutionStatus: bars pulling live execution state, one per status.
+// ---------------------------------------------------------------------------
+
+// The Storybook execution-state decorator (storybook-utils/decorators.tsx)
+// derives status from the id's second dash-separated segment, so each id here
+// is `exec-`.
+const EXECUTION_STATUS_STEPS: Array<{ id: string; type: string; label: string }> = [
+ { id: 'exec-Completed', type: 'uipath.script', label: 'Validate input' },
+ { id: 'exec-InProgress', type: 'uipath.data.transform', label: 'Transform payload' },
+ { id: 'exec-ActionNeeded', type: 'uipath.human-task.approval', label: 'Manager approval' },
+ { id: 'exec-Failed', type: 'uipath.workflow.call', label: 'Sync to ERP' },
+ { id: 'exec-NotExecuted', type: 'uipath.control-flow.decision', label: 'Check retry' },
+];
+
+function createExecutionStatusFixture(): { nodes: Node[]; edges: Edge[] } {
+ const nodes: Node[] = EXECUTION_STATUS_STEPS.map(({ id, type, label }) => ({
+ id,
+ type,
+ position: { x: 0, y: 0 },
+ data: { display: { label } },
+ }));
+ const edges: Edge[] = EXECUTION_STATUS_STEPS.slice(1).map((step, index) => ({
+ id: `e-${EXECUTION_STATUS_STEPS[index]!.id}-${step.id}`,
+ source: EXECUTION_STATUS_STEPS[index]!.id,
+ target: step.id,
+ sourceHandle: 'output',
+ targetHandle: 'input',
+ }));
+ return { nodes, edges };
+}
+
+function ExecutionStatusStory() {
+ const fixture = useMemo(() => createExecutionStatusFixture(), []);
+ return (
+
+
+
+ );
+}
+
+export const ExecutionStatus: Story = {
+ name: 'Execution Status',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Five bars, one per execution status: Completed, InProgress, ActionNeeded, Failed, and NotExecuted. Rendered in view mode to demonstrate the sequential view working outside design mode (open product question Q5).',
+ },
+ },
+ },
+ render: () => ,
+};
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx
new file mode 100644
index 000000000..f99739a7c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.tsx
@@ -0,0 +1,813 @@
+import type {
+ Edge,
+ EdgeChange,
+ EdgeTypes,
+ Node,
+ NodeChange,
+ NodeTypes,
+ XYPosition,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import {
+ applyEdgeChanges,
+ applyNodeChanges,
+ Position,
+ ReactFlowProvider,
+ useReactFlow,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import type { Dispatch, SetStateAction } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ DEFAULT_SOURCE_HANDLE_ID,
+ DEFAULT_TRIGGER_NODE_TYPE,
+ PREVIEW_NODE_ID,
+} from '../../constants';
+import { useOptionalNodeTypeRegistry } from '../../core';
+import { usePreviewNode } from '../../hooks/usePreviewNode';
+import { isContainerNodeManifest } from '../../utils';
+import { isPreviewEdge } from '../../utils/createPreviewNode';
+import { resolveHandles } from '../../utils/manifest-resolver';
+import { SEQ_LANE_PLACEHOLDER_PREFIX } from '../../utils/sequential/graph-helpers';
+import { removeStep } from '../../utils/sequential/mutations';
+import type { InsertionSlot } from '../../utils/sequential/sequential.types';
+import { AddNodeManager } from '../AddNodePanel/AddNodeManager';
+import { BaseCanvas } from '../BaseCanvas/BaseCanvas';
+import { BaseNode } from '../BaseNode/BaseNode';
+import { SequentialConnectorEdge } from './edges/SequentialConnectorEdge';
+import { SEQUENTIAL_IGNORED_NODE_TYPES } from './edges/sequentialInsert';
+import { useSequentialInsert } from './edges/useSequentialInsert';
+import {
+ SEQUENTIAL_SYNTHETIC_NODE_TYPES,
+ SequentialInsertPreviewNode,
+ SequentialStepNode,
+} from './nodes';
+import { resolveFlowEdgeTypes } from './resolveFlowEdgeTypes';
+import { resolveFlowNodeComponent } from './resolveFlowNodeComponent';
+import { SequentialAccessibleList } from './SequentialAccessibleList';
+import type { SequentialCanvasProps } from './SequentialCanvas.types';
+import { SequentialCollapsedRowsProvider } from './SequentialCollapsedRowsContext';
+import { SequentialGutter } from './SequentialGutter';
+import { SequentialInsertGapProvider } from './SequentialInsertGapContext';
+import { SequentialInsertStateProvider } from './SequentialInsertStateContext';
+import { SequentialMoveActionsProvider } from './SequentialMoveActionsContext';
+import { useOptionalSequentialView } from './SequentialViewContext';
+import {
+ forwardSequentialEdgeChanges,
+ forwardSequentialNodeChanges,
+ graphChangeSetToEdgeChanges,
+ graphChangeSetToNodeChanges,
+} from './sequentialChangeFilters';
+import {
+ SEQ_CONNECTOR_EDGE_TYPE,
+ SEQ_FULL_RENDER_MAX_NODES,
+ SEQ_PLACEHOLDER_ROW_ID,
+} from './sequentialGraph.constants';
+import {
+ getSequentialMoveSlot,
+ resolveTailInsertionSlot,
+ type SequentialMoveDirection,
+} from './sequentialMoveActions';
+import { useCanvasViewViewport } from './useCanvasViewViewport';
+import { SEQ_SYNTHETIC_ROW_IDS, useSequentialGraph } from './useSequentialGraph';
+import { toggleCollapsedStepIds, useSequentialKeyboard } from './useSequentialKeyboard';
+import { useSequentialMoveActionsValue } from './useSequentialMoveActionsValue';
+
+const EDGE_TYPES: EdgeTypes = {
+ [SEQ_CONNECTOR_EDGE_TYPE]: SequentialConnectorEdge as EdgeTypes[string],
+ // The Add Node preview pipeline stamps its transient edges `type: 'default'`.
+ // Render those through the same connector so the preview traces the real
+ // orthogonal rounded path (with an arrowhead) instead of xyflow's built-in
+ // bezier fallback, which is the only 'default' edge that occurs in this view.
+ default: SequentialConnectorEdge as EdgeTypes[string],
+};
+
+const EMPTY_POSITIONS: ReadonlyMap = new Map();
+
+/**
+ * The Sequential Canvas view: an n8n/Zapier-style vertical projection of the
+ * same flow graph, rendered through the existing BaseCanvas (D1-D14). It renders
+ * either the canonical flow graph or its sequential projection on the same
+ * mounted BaseCanvas, selected by the orthogonal `view` prop (see ToggleHarness
+ * / SequentialViewProvider). Mutations flow out through the standard
+ * onNodesChange / onEdgesChange callbacks, with synthetic rows and view-only
+ * geometry filtered out (seam 2); canonical positions are never written back
+ * (D4).
+ *
+ * It supplies its own ReactFlowProvider (BaseCanvas requires one above it,
+ * BaseCanvas.tsx:116-122) so it is a self-contained drop-in, matching the
+ * MiniCanvasNavigator idiom rather than requiring the host to wrap it.
+ */
+export function SequentialCanvas(
+ props: SequentialCanvasProps
+) {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Owns insertion-gap state above every insertion hook in the canvas. Keeping
+ * the provider here is essential: terminal/lane/leaf handlers are created by
+ * SequentialCanvasInner itself, while connector handlers are rendered below
+ * BaseCanvas. Both must publish into the same state sink before the preview is
+ * opened.
+ */
+function SequentialCanvasInsertController(
+ props: SequentialCanvasProps
+) {
+ const [activeInsertSlot, setActiveInsertSlot] = useState(undefined);
+ const { previewNode } = usePreviewNode();
+ const wasPreviewOpenRef = useRef(false);
+
+ useEffect(() => {
+ if (previewNode) {
+ wasPreviewOpenRef.current = true;
+ } else if (wasPreviewOpenRef.current) {
+ wasPreviewOpenRef.current = false;
+ setActiveInsertSlot(undefined);
+ }
+ }, [previewNode]);
+
+ return (
+
+
+
+ );
+}
+
+function SequentialCanvasInner({
+ nodes,
+ edges,
+ view = 'sequential',
+ sequenceLayoutOptions,
+ isSequenceNode,
+ flowNodeTypes,
+ flowEdgeTypes,
+ onNodesChange,
+ onEdgesChange,
+ collapsedStepIds,
+ onCollapsedStepIdsChange,
+ onPrimaryAction,
+ onAddTrigger,
+ addNodeManagerProps,
+ canvasRef,
+ mode = 'view',
+ isDarkMode,
+ locale,
+ fitViewOptions,
+ onToolbarAction,
+ breakpoints,
+ children,
+ activeInsertSlot,
+ setActiveInsertSlot,
+}: SequentialCanvasProps & {
+ activeInsertSlot?: InsertionSlot;
+ setActiveInsertSlot: Dispatch>;
+}) {
+ const reactFlow = useReactFlow();
+ const { startInsert, onBeforeNodeAdded } = useSequentialInsert();
+ const seqView = useOptionalSequentialView();
+ const registry = useOptionalNodeTypeRegistry();
+ const isDesignMode = mode === 'design';
+ // Canonical id->node map, reused everywhere a node lookup is needed (handle
+ // resolution, change forwarding, move commits). `childParentIds` is the set of
+ // ids that are some node's `parentId`, i.e. structural containers, so
+ // `isContainerNode` is an O(1) lookup instead of an O(n) scan per call.
+ const canonicalById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
+ const childParentIds = useMemo(() => {
+ const ids = new Set();
+ for (const node of nodes) {
+ if (node.parentId) ids.add(node.parentId);
+ }
+ return ids;
+ }, [nodes]);
+
+ const resolvedHandlesByNodeId = useMemo(() => {
+ const result = new Map>();
+ for (const node of nodes) {
+ const manifest = node.type ? registry?.getManifest(node.type) : undefined;
+ if (!manifest) continue;
+ result.set(
+ node.id,
+ resolveHandles(manifest.handleConfiguration, {
+ ...(node.data as Record),
+ nodeId: node.id,
+ })
+ );
+ }
+ return result;
+ }, [nodes, registry]);
+
+ const findResolvedHandle = useCallback(
+ (nodeId: string, handleId: string | null | undefined, type: 'source' | 'target') => {
+ const nodeType = canonicalById.get(nodeId)?.type;
+ const effectiveHandleId =
+ handleId ?? (nodeType ? registry?.getDefaultHandle(nodeType, type)?.id : undefined);
+ const groups = resolvedHandlesByNodeId.get(nodeId);
+ if (!groups) return undefined;
+ for (const group of groups) {
+ for (const handle of group.handles) {
+ if (handle.type === type && handle.id === effectiveHandleId) return handle;
+ }
+ }
+ return undefined;
+ },
+ [resolvedHandlesByNodeId, canonicalById, registry]
+ );
+
+ const isSequenceEdge = useCallback(
+ (edge: E) =>
+ findResolvedHandle(edge.source, edge.sourceHandle, 'source')?.handleType !== 'artifact' &&
+ findResolvedHandle(edge.target, edge.targetHandle, 'target')?.handleType !== 'artifact',
+ [findResolvedHandle]
+ );
+
+ const isStartNode = useCallback(
+ (node: N) =>
+ node.type === DEFAULT_TRIGGER_NODE_TYPE ||
+ (node.type ? registry?.getManifest(node.type)?.category === 'trigger' : false),
+ [registry]
+ );
+
+ const isProjectedContainerNode = useCallback(
+ (node: N) => isContainerNodeManifest(node.type ? registry?.getManifest(node.type) : undefined),
+ [registry]
+ );
+
+ const isProjectedSequenceNode = useCallback(
+ (node: N) => (isSequenceNode ? isSequenceNode(node) : node.type !== 'stickyNote'),
+ [isSequenceNode]
+ );
+
+ const resolveBranchLabel = useCallback(
+ (nodeId: string, handleId: string) =>
+ findResolvedHandle(nodeId, handleId, 'source')?.label ?? handleId,
+ [findResolvedHandle]
+ );
+
+ // A parent node's resolved branch-lane handles (If → true/false, while → body,
+ // try/catch → try/catch/finally), so the projection can render every lane —
+ // empty ones as "+ Add step" placeholders — before any child edge exists. The
+ // continuation output is excluded when it uses a well-known `output` or
+ // `success` handle, or when it is the node's only source. `isDefaultForType`
+ // is intentionally not used as a branch discriminator: it describes default
+ // connection selection, not control-flow semantics.
+ const getBranchHandles = useCallback(
+ (node: N) => {
+ const candidates = (resolvedHandlesByNodeId.get(node.id) ?? [])
+ .flatMap((group) => group.handles)
+ .filter(
+ (handle) => handle.type === 'source' && handle.handleType !== 'artifact' && handle.visible
+ );
+ const continuationId =
+ candidates.find((handle) => handle.id === 'output' || handle.id === 'success')?.id ??
+ (candidates.length === 1 ? candidates[0]?.id : undefined);
+ return candidates
+ .filter((handle) => handle.id !== continuationId)
+ .map((handle) => ({ id: handle.id, label: resolveBranchLabel(node.id, handle.id) }));
+ },
+ [resolvedHandlesByNodeId, resolveBranchLabel]
+ );
+
+ const collapsedSet = useMemo(() => new Set(collapsedStepIds ?? []), [collapsedStepIds]);
+
+ // The slot whose Add Node panel is currently open, if any. Written by every
+ // useSequentialInsert() call site via SequentialInsertGapProvider, and cleared
+ // the moment the preview node closes -- covering BOTH cancel (the gap just
+ // closes) and commit (a real structural change relayouts right behind it; a
+ // same-frame flicker is acceptable). While set, useSequentialGraph re-runs ONLY
+ // its cheap layout pass with a preview row spliced in at the slot (the
+ // projection memo stays fingerprint-keyed, D12), so the gap opens exactly one
+ // row, the preview bar seats in its slot, and every connector re-routes from
+ // the shifted geometry.
+ // Terminal "Add step" placeholder: append after the last top-level row. Read
+ // from a ref so the callback identity stays stable (it feeds the synthetic
+ // placeholder's data, which must not churn every render).
+ const tailSlotRef = useRef(undefined);
+ const onPlaceholderAdd = useCallback(() => {
+ if (!isDesignMode || view !== 'sequential') return;
+ const slot = tailSlotRef.current;
+ if (!slot?.source) return;
+ const placeholder = reactFlow.getNode(SEQ_PLACEHOLDER_ROW_ID);
+ startInsert({
+ slot,
+ source: slot.source.nodeId,
+ sourceHandleId: slot.source.handleId ?? DEFAULT_SOURCE_HANDLE_ID,
+ target: '',
+ targetHandleId: undefined,
+ sourcePosition: Position.Bottom,
+ position: placeholder?.position ?? { x: 0, y: 0 },
+ });
+ }, [isDesignMode, view, reactFlow, startInsert]);
+
+ // An empty branch lane's "+ Add step" placeholder: append the first node into
+ // that lane via the carried slot (source = parent, sourceHandle = the branch
+ // handle, containerId set so it parents correctly). The preview then re-seats
+ // in the lane via the layout swap (projectionWithPreviewRow).
+ const onLaneAdd = useCallback(
+ (slot: InsertionSlot) => {
+ if (!isDesignMode || view !== 'sequential' || !slot.source) return;
+ const sourceNode = canonicalById.get(slot.source.nodeId);
+ const resolvedSource = {
+ ...slot.source,
+ handleId:
+ slot.source.handleId ??
+ (sourceNode?.type
+ ? registry?.getDefaultHandle(sourceNode.type, 'source')?.id
+ : undefined) ??
+ DEFAULT_SOURCE_HANDLE_ID,
+ };
+ const resolvedSlot: InsertionSlot = {
+ ...slot,
+ source: resolvedSource,
+ };
+ const placeholder = reactFlow
+ .getNodes()
+ .find(
+ (node) =>
+ (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId === slot.id
+ );
+ startInsert({
+ slot: resolvedSlot,
+ source: resolvedSource.nodeId,
+ sourceHandleId: undefined,
+ target: '',
+ targetHandleId: undefined,
+ sourcePosition: Position.Bottom,
+ position: placeholder?.position ?? { x: 0, y: 0 },
+ });
+ },
+ [isDesignMode, view, canonicalById, registry, reactFlow, startInsert]
+ );
+
+ const {
+ nodes: seqNodesRaw,
+ edges: seqEdges,
+ projection,
+ layout,
+ } = useSequentialGraph({
+ nodes,
+ edges,
+ view,
+ collapsedStepIds: collapsedSet,
+ onAddTrigger: isDesignMode && view === 'sequential' ? onAddTrigger : undefined,
+ onPlaceholderAdd: isDesignMode && view === 'sequential' ? onPlaceholderAdd : undefined,
+ isSequenceEdge,
+ isSequenceNode: isProjectedSequenceNode,
+ isStartNode,
+ isContainerNode: isProjectedContainerNode,
+ resolveBranchLabel,
+ getBranchHandles,
+ onLaneAdd: isDesignMode && view === 'sequential' ? onLaneAdd : undefined,
+ insertSlot: view === 'sequential' ? activeInsertSlot : undefined,
+ layoutOptions: sequenceLayoutOptions,
+ });
+
+ const tailSlot = useMemo(() => {
+ return resolveTailInsertionSlot(
+ projection,
+ nodes,
+ (nodeType) => registry?.getDefaultHandle(nodeType, 'source')?.id,
+ isStartNode
+ );
+ }, [projection, nodes, registry, isStartNode]);
+ tailSlotRef.current = tailSlot;
+
+ // The Add Node pipeline (showPreviewGraph) adds its `preview` node + preview
+ // edges via `instance.setNodes` / `instance.setEdges`. This canvas is
+ // CONTROLLED (nodes/edges come from the derived arrays), and in that mode
+ // xyflow routes them through onNodesChange / onEdgesChange as `add` changes
+ // rather than writing the store; the handlers below capture the preview into
+ // this local state and we merge it into the controlled arrays here (it never
+ // reaches canonical state -- the change filters drop PREVIEW_NODE_ID /
+ // isPreviewEdge). The preview node's position is overridden with the slot the
+ // layout opened (layout.positions[PREVIEW_NODE_ID]), so the ghost bar sits
+ // centered in the gap instead of at the click-time midpoint.
+ const [insertPreviewNode, setInsertPreviewNode] = useState(null);
+ const [insertPreviewEdges, setInsertPreviewEdges] = useState([]);
+ const seqNodes = useMemo(() => {
+ // A lane placeholder loses its layout position when its lane is being
+ // inserted into (projectionWithPreviewRow swaps it for the preview row), so
+ // drop the now-orphaned placeholder; every other lane placeholder still has a
+ // position and renders normally.
+ const base = seqNodesRaw.filter(
+ (node) => !node.id.startsWith(SEQ_LANE_PLACEHOLDER_PREFIX) || !!layout?.positions.has(node.id)
+ );
+ if (view !== 'sequential' || !insertPreviewNode) return base;
+ const slotPosition = layout?.positions.get(PREVIEW_NODE_ID);
+ const positionedPreview = slotPosition
+ ? {
+ ...insertPreviewNode,
+ position: slotPosition,
+ width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width,
+ height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height,
+ }
+ : {
+ ...insertPreviewNode,
+ width: sequenceLayoutOptions?.barWidth ?? insertPreviewNode.width,
+ height: sequenceLayoutOptions?.barHeight ?? insertPreviewNode.height,
+ };
+ return [...base, positionedPreview];
+ }, [
+ view,
+ seqNodesRaw,
+ insertPreviewNode,
+ layout,
+ sequenceLayoutOptions?.barWidth,
+ sequenceLayoutOptions?.barHeight,
+ ]);
+ const seqEdgesWithPreview = useMemo(
+ () =>
+ // While a slot is active, useSequentialGraph has already replaced the
+ // affected connector with routed preview connectors. The raw edges from
+ // showPreviewGraph remain useful to AddNodeManager's store bookkeeping,
+ // but rendering them too would duplicate the path and use click-time
+ // geometry that jumps when the real node is committed.
+ view === 'sequential' && activeInsertSlot
+ ? seqEdges
+ : view === 'sequential' && insertPreviewEdges.length > 0
+ ? [...seqEdges, ...insertPreviewEdges]
+ : seqEdges,
+ [view, activeInsertSlot, seqEdges, insertPreviewEdges]
+ );
+ useEffect(() => {
+ if (view !== 'flow') return;
+ setInsertPreviewNode(null);
+ setInsertPreviewEdges([]);
+ setActiveInsertSlot(undefined);
+ }, [view, setActiveInsertSlot]);
+
+ // Every real manifest type maps to the bar step node; synthetic rows use their
+ // own components. Memoized on the set of types present so it is reference-stable
+ // across data/position changes.
+ const nodeTypeKey = useMemo(
+ () => [...new Set(nodes.map((node) => node.type ?? 'default'))].sort().join('|'),
+ [nodes]
+ );
+ const sequentialNodeTypes = useMemo(() => {
+ const types: NodeTypes = { ...SEQUENTIAL_SYNTHETIC_NODE_TYPES };
+ for (const key of nodeTypeKey.split('|')) {
+ if (key && !types[key]) types[key] = SequentialStepNode;
+ }
+ types.default ??= SequentialStepNode;
+ // The Add Node pipeline (showPreviewGraph) drops a `preview`-typed node into
+ // the store while the panel is open; without this it cannot render (D2). The
+ // sequential view uses a bar-shaped ghost (icon left + "New step") instead of
+ // the flow-view square AddNodePreview, which reads as an empty slab at the
+ // bar's 16:1 aspect ratio.
+ types.preview ??= SequentialInsertPreviewNode;
+ return types;
+ }, [nodeTypeKey]);
+ const resolvedFlowNodeTypes = useMemo(() => {
+ if (flowNodeTypes) return flowNodeTypes;
+ const types: NodeTypes = {};
+ for (const key of nodeTypeKey.split('|')) {
+ if (key) {
+ types[key] = resolveFlowNodeComponent(registry?.getManifest(key));
+ }
+ }
+ types.default ??= BaseNode;
+ return types;
+ }, [flowNodeTypes, nodeTypeKey, registry]);
+ const nodeTypes = view === 'sequential' ? sequentialNodeTypes : resolvedFlowNodeTypes;
+ const resolvedFlowEdgeTypes = useMemo(() => resolveFlowEdgeTypes(flowEdgeTypes), [flowEdgeTypes]);
+ const edgeTypes = view === 'sequential' ? EDGE_TYPES : resolvedFlowEdgeTypes;
+
+ // Change forwarding (seam 2): position/dimension changes and synthetic rows are
+ // dropped; renames merge onto canonical; inserts + their healed edges pass
+ // through and the split canonical edge is removed.
+ const canonicalEdgeIds = useMemo(() => new Set(edges.map((edge) => edge.id)), [edges]);
+ const syntheticNodeIds = useMemo(() => {
+ const ids = new Set(SEQ_SYNTHETIC_ROW_IDS);
+ for (const row of projection?.rows ?? []) {
+ if (row.lanePlaceholder) ids.add(row.nodeId);
+ }
+ return ids;
+ }, [projection]);
+
+ const handleNodesChange = useCallback(
+ (changes: NodeChange[]) => {
+ if (view === 'flow') {
+ onNodesChange?.(changes);
+ return;
+ }
+ // Capture preview-node changes into local state (see the insertPreview
+ // note above): apply them with xyflow's own reducer so add / remove /
+ // replace / select / dimensions all behave correctly, then merge the
+ // result into the controlled array. This runs regardless of whether the
+ // host wired onNodesChange, since the preview is a view-only affordance.
+ const previewChanges = changes.filter(
+ (change) => (change.type === 'add' ? change.item.id : change.id) === PREVIEW_NODE_ID
+ );
+ if (previewChanges.length > 0) {
+ setInsertPreviewNode((current) => {
+ const next = applyNodeChanges(previewChanges, current ? [current] : []);
+ return (next[0] as N | undefined) ?? null;
+ });
+ }
+
+ if (!onNodesChange) return;
+ const forwarded = forwardSequentialNodeChanges(changes, syntheticNodeIds, canonicalById);
+ if (forwarded.length > 0) onNodesChange(forwarded);
+ },
+ [view, onNodesChange, syntheticNodeIds, canonicalById]
+ );
+
+ const handleEdgesChange = useCallback(
+ (changes: EdgeChange[]) => {
+ if (view === 'flow') {
+ onEdgesChange?.(changes);
+ return;
+ }
+ // Capture preview-edge changes the same way: an `add` is a preview edge
+ // when isPreviewEdge matches; a remove/select/replace targets a preview
+ // edge when it references one we are already holding.
+ setInsertPreviewEdges((current) => {
+ const relevant = changes.filter((change) =>
+ change.type === 'add'
+ ? isPreviewEdge(change.item)
+ : current.some((edge) => edge.id === change.id)
+ );
+ return relevant.length > 0 ? (applyEdgeChanges(relevant, current) as E[]) : current;
+ });
+
+ if (!onEdgesChange) return;
+ const forwarded = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, edges);
+ if (forwarded.length > 0) onEdgesChange(forwarded);
+ },
+ [view, onEdgesChange, canonicalEdgeIds, edges]
+ );
+
+ // Explorer-like tree move operations: kebab items (BaseNode's new `extraMenuItems`, see
+ // useSequentialMoveMenuItems) and Alt+Arrow keyboard both read/commit
+ // through this SAME binding, so the two affordances can never disagree.
+ const isContainerNode = useCallback(
+ (nodeId: string) => {
+ if (childParentIds.has(nodeId)) return true;
+ const node = canonicalById.get(nodeId);
+ return !!node && isProjectedContainerNode(node);
+ },
+ [canonicalById, childParentIds, isProjectedContainerNode]
+ );
+
+ const getDefaultSourceHandleId = useCallback(
+ (nodeType: string) => registry?.getDefaultHandle(nodeType, 'source')?.id,
+ [registry]
+ );
+
+ const deleteStep = useCallback(
+ (nodeId: string) => {
+ if (!isDesignMode || view !== 'sequential' || !projection) return;
+ const changeSet = removeStep(projection, nodeId, { nodes, edges });
+ onNodesChange?.(graphChangeSetToNodeChanges(changeSet));
+ onEdgesChange?.(graphChangeSetToEdgeChanges(changeSet));
+ },
+ [isDesignMode, view, projection, nodes, edges, onNodesChange, onEdgesChange]
+ );
+
+ const handleToolbarAction = useCallback<
+ NonNullable['onToolbarAction']>
+ >(
+ (event) => {
+ if (view === 'sequential' && event.actionId === 'delete') {
+ deleteStep(event.nodeId);
+ return;
+ }
+ onToolbarAction?.(event);
+ },
+ [view, deleteStep, onToolbarAction]
+ );
+
+ // Reference-chip navigation (D9 density fallback) shares this same binding
+ // (team guidance: thread it "via the same context as the move actions").
+ // Mirrors BaseCanvas's own centerNode (BaseCanvas.hooks.ts) directly against
+ // the raw ReactFlow instance already held above, since that instance -- not
+ // the imperative BaseCanvasRef, which is only for external consumers -- is
+ // what this component itself has in scope.
+ const centerViewportOnNode = useCallback(
+ (nodeId: string) => {
+ const node = reactFlow.getInternalNode(nodeId);
+ if (!node) return;
+ const width = node.measured?.width ?? node.width;
+ const height = node.measured?.height ?? node.height;
+ if (!width || !height) return;
+ reactFlow.setCenter(node.position.x + width / 2, node.position.y + height / 2, {
+ zoom: reactFlow.getViewport().zoom,
+ duration: 300,
+ });
+ },
+ [reactFlow]
+ );
+
+ // Move-actions binding shared by the kebab items and Alt+Arrow keyboard.
+ // Rebuilt only on a structural projection change, so its identity is stable
+ // across a selection or data-only change; every consuming SequentialStepNode
+ // reads it through context, so a churning value would re-render every bar on
+ // each click. The commit path reads the latest graph at call time. See
+ // useSequentialMoveActionsValue.
+ const moveActionsValue = useSequentialMoveActionsValue({
+ projection,
+ nodes,
+ edges,
+ canonicalById,
+ isContainerNode,
+ getDefaultSourceHandleId,
+ onNodesChange,
+ onEdgesChange,
+ centerOnNode: centerViewportOnNode,
+ });
+
+ // Alt+Arrow keyboard move: same guards as the kebab (design mode
+ // only) and the same commit path (commitMove), so kebab and keyboard can
+ // never disagree.
+ const handleMoveNode = useCallback(
+ (nodeId: string, direction: SequentialMoveDirection) => {
+ if (!isDesignMode) return;
+ const slot = getSequentialMoveSlot(moveActionsValue.getMoveOptions(nodeId), direction);
+ if (slot) moveActionsValue.commitMove(nodeId, slot);
+ },
+ [isDesignMode, moveActionsValue]
+ );
+
+ // Gutter + keyboard (D8): the visible, numbered row order is the single
+ // source of truth both the gutter's layout and ArrowUp/Down navigation walk,
+ // so DOM order / gutter order / keyboard order can never disagree.
+ const visibleNumberedRows = useMemo(
+ () => projection?.rows.filter((row) => row.visible && row.stepNumber !== undefined) ?? [],
+ [projection]
+ );
+
+ // Sequential bars are not user-connectable, but selection still flows through
+ // the canonical `nodes` array (the sequential clones pass `selected` through
+ // by reference, see useSequentialGraph) -- read it back the same way so
+ // keyboard nav agrees with whatever a mouse click last selected.
+ const selectedNodeId = useMemo(() => nodes.find((node) => node.selected)?.id, [nodes]);
+
+ const isInteractive = mode !== 'readonly';
+
+ // Moves single selection to a row's node id through the SAME onNodesChange
+ // path a mouse click already uses (`select` NodeChanges), so keyboard and
+ // pointer selection are indistinguishable to the consumer's canonical state.
+ // Gated on `isInteractive` to mirror BaseCanvas's own selection gating
+ // (BaseCanvas.tsx onNodeClick/elementsSelectable), so readonly canvases don't
+ // let the keyboard select what a click cannot.
+ const handleSelectRow = useCallback(
+ (nodeId: string) => {
+ if (!isInteractive) return;
+ const changes: NodeChange[] = [];
+ for (const node of nodes) {
+ if (node.id === nodeId) {
+ if (!node.selected) changes.push({ id: node.id, type: 'select', selected: true });
+ } else if (node.selected) {
+ changes.push({ id: node.id, type: 'select', selected: false });
+ }
+ }
+ if (changes.length > 0) handleNodesChange(changes);
+ },
+ [isInteractive, nodes, handleNodesChange]
+ );
+
+ // Collapse is view-local UI (D6), not a canonical mutation, so it stays
+ // available regardless of mode (a readonly/monitoring canvas can still
+ // fold sections). Shared by both the gutter's chevron click and the
+ // keyboard's ArrowLeft/Right so the two affordances can never disagree.
+ const handleToggleCollapse = useCallback(
+ (nodeId: string, collapsed: boolean) => {
+ onCollapsedStepIdsChange?.(toggleCollapsedStepIds(collapsedSet, nodeId, collapsed));
+ },
+ [collapsedSet, onCollapsedStepIdsChange]
+ );
+
+ const { onKeyDown } = useSequentialKeyboard({
+ rows: visibleNumberedRows,
+ selectedNodeId,
+ collapsedStepIds: collapsedSet,
+ onSelectNode: handleSelectRow,
+ onCollapsedStepIdsChange,
+ onMoveNode: handleMoveNode,
+ onDeleteNode: deleteStep,
+ onPrimaryAction,
+ isDesignMode,
+ });
+
+ const composedOnBeforeNodeAdded = useCallback(
+ (newNode: Node, newEdges: Edge[]) => {
+ const hostResult = addNodeManagerProps?.onBeforeNodeAdded?.(newNode, newEdges) ?? {
+ newNode,
+ newEdges,
+ };
+ return onBeforeNodeAdded(hostResult.newNode, hostResult.newEdges);
+ },
+ [addNodeManagerProps?.onBeforeNodeAdded, onBeforeNodeAdded]
+ );
+ const ignoredNodeTypes = useMemo(
+ () => [
+ ...new Set([
+ ...SEQUENTIAL_IGNORED_NODE_TYPES,
+ ...(addNodeManagerProps?.ignoredNodeTypes ?? []),
+ ]),
+ ],
+ [addNodeManagerProps?.ignoredNodeTypes]
+ );
+ const {
+ onBeforeNodeAdded: _hostOnBeforeNodeAdded,
+ ignoredNodeTypes: _hostIgnoredNodeTypes,
+ ...remainingAddNodeManagerProps
+ } = addNodeManagerProps ?? {};
+
+ // Per-view viewport save/restore (D11). The hook keeps a local fallback so a
+ // standalone SequentialCanvas does not re-center on every toggle, and mirrors
+ // it into SequentialViewProvider when the host opts into persisted state.
+ const { onMove: handleMove, defaultViewport } = useCanvasViewViewport({
+ view,
+ reactFlow,
+ externalStore: seqView,
+ fitViewOptions,
+ fitOnMount: view === 'sequential',
+ });
+
+ // Render every row into the DOM for reading-order a11y (D8), but only up to a
+ // ceiling: past it, re-enable xyflow's viewport virtualization so a mount
+ // burst of many hundred fixed bars can't drive xyflow's ResizeObserver /
+ // updateNodeInternals cycle past React's nested-update limit. See
+ // SEQ_FULL_RENDER_MAX_NODES for the tradeoff.
+ const virtualizeForScale = view === 'sequential' && seqNodes.length > SEQ_FULL_RENDER_MAX_NODES;
+
+ return (
+ // Keyboard nav (D8) is attached to this wrapper div rather than `document`
+ // (see useSequentialKeyboard's doc): it only reacts once focus/bubbling
+ // genuinely lands inside this canvas, so multiple canvases on one page
+ // never steal each other's key events.
+ //
+ // SequentialCollapsedRowsProvider wraps the whole subtree so every
+ // SequentialStepNode xyflow mounts underneath BaseCanvas can read the
+ // collapsed row-id set and render the "stacked" treatment without the
+ // collapse toggle ever touching node.data (D12).
+
+
+
+
+ ref={canvasRef}
+ nodes={seqNodes}
+ edges={seqEdgesWithPreview}
+ nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
+ mode={mode}
+ isDarkMode={isDarkMode}
+ locale={locale}
+ fitViewOptions={fitViewOptions}
+ onToolbarAction={handleToolbarAction}
+ breakpoints={breakpoints}
+ // Accessibility: keep every row in the DOM so reading order == row
+ // order (D8), except past SEQ_FULL_RENDER_MAX_NODES where
+ // virtualization is re-enabled for stability at scale.
+ onlyRenderVisibleElements={virtualizeForScale}
+ aria-hidden={virtualizeForScale || undefined}
+ // Sequential bars are not user-connectable in v1.
+ nodesConnectable={view === 'flow'}
+ onNodesChange={handleNodesChange}
+ onEdgesChange={handleEdgesChange}
+ onMove={handleMove}
+ defaultViewport={defaultViewport}
+ >
+ {children}
+ {view === 'sequential' && (
+ <>
+ {isDesignMode && (
+
+ )}
+
+ >
+ )}
+
+ {virtualizeForScale && projection && (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts
new file mode 100644
index 000000000..08cdbb69a
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCanvas.types.ts
@@ -0,0 +1,70 @@
+import type {
+ Edge,
+ EdgeTypes,
+ Node,
+ NodeTypes,
+ OnEdgesChange,
+ OnNodesChange,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import type { Ref } from 'react';
+import type { CanvasView, LayoutSequenceOptions } from '../../utils/sequential/sequential.types';
+import type { AddNodeManagerProps } from '../AddNodePanel/AddNodeManager';
+import type { BaseCanvasProps, BaseCanvasRef } from '../BaseCanvas/BaseCanvas.types';
+
+/**
+ * Public props for the sequential view. It renders through the existing
+ * BaseCanvas, so it borrows a curated subset of BaseCanvasProps and never
+ * exposes the free-form node/edge handlers directly. Mutations still flow out
+ * through onNodesChange / onEdgesChange (D10); synthetic rows (start bar,
+ * placeholder) are filtered out before those callbacks fire.
+ */
+export interface SequentialCanvasProps
+ extends Pick<
+ BaseCanvasProps,
+ | 'mode'
+ | 'isDarkMode'
+ | 'locale'
+ | 'fitViewOptions'
+ | 'onToolbarAction'
+ | 'breakpoints'
+ | 'children'
+ > {
+ /** Canonical graph; flow-view positions are untouched (D4). */
+ nodes: N[];
+ edges: E[];
+ /** Render the canonical flow graph or its sequential projection without remounting BaseCanvas. */
+ view?: CanvasView;
+ /** Optional sequential-view geometry overrides. Flow-view geometry is untouched. */
+ sequenceLayoutOptions?: LayoutSequenceOptions;
+ /**
+ * Controls which canonical nodes participate in the sequential projection.
+ * Excluded presentation-only nodes remain untouched and reappear in Flow.
+ * Sticky notes are excluded by default.
+ */
+ isSequenceNode?: (node: N) => boolean;
+ /** Node registrations used while `view="flow"`. */
+ flowNodeTypes?: NodeTypes;
+ /** Flow edge registrations merged over the standard `SequenceEdge` default. */
+ flowEdgeTypes?: EdgeTypes;
+ /** Synthetic rows are filtered out before forwarding. */
+ onNodesChange?: OnNodesChange;
+ onEdgesChange?: OnEdgesChange;
+ /** Controlled, view-local collapse state (D6). */
+ collapsedStepIds?: string[];
+ onCollapsedStepIdsChange?: (ids: string[]) => void;
+ /** Primary keyboard action invoked by Enter on the selected step. */
+ onPrimaryAction?: (nodeId: string) => void;
+ /** "Add trigger" button on the start bar. */
+ onAddTrigger?: () => void;
+ addNodeManagerProps?: Partial;
+ canvasRef?: Ref>;
+}
+
+/**
+ * Segmented flow/sequential control. Controlled: the host owns `value` and
+ * `onChange`.
+ */
+export interface ViewSwitcherProps {
+ value: CanvasView;
+ onChange: (view: CanvasView) => void;
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx
new file mode 100644
index 000000000..f119eab8a
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialCollapsedRowsContext.tsx
@@ -0,0 +1,43 @@
+import { createContext, type ReactNode, useContext } from 'react';
+
+const EMPTY_COLLAPSED_ROWS: ReadonlySet = new Set();
+
+const SequentialCollapsedRowsContext = createContext>(EMPTY_COLLAPSED_ROWS);
+
+export interface SequentialCollapsedRowsProviderProps {
+ children: ReactNode;
+ /** The view-local collapsed row-id set (SequentialCanvasInner's `collapsedSet`). */
+ collapsedStepIds: ReadonlySet;
+}
+
+/**
+ * Carries the Sequential Canvas's view-local `collapsedStepIds` set down to
+ * `SequentialStepNode` without touching node `data`. A
+ * collapsed collapsible row must render its bar with the same decorative
+ * "stacked" treatment BaseNode's card uses for drillable/collapsed nodes, but
+ * the sequential clone's `data` is a by-reference passthrough from the
+ * canonical node (see `useSequentialGraph.ts`) and must not be mutated (D12);
+ * mutating it per collapse toggle would also break the clone/canonical
+ * reference-equality memoization that keeps rename keystrokes cheap.
+ *
+ * Kept as a tiny dedicated context (rather than folding into the broader,
+ * OPTIONAL `SequentialViewContext`) because `collapsedStepIds` is a required,
+ * always-present concept of `SequentialCanvas` itself, whereas
+ * `SequentialViewContext` only exists for hosts that compose an explicit
+ * flow/sequential toggle and may not be mounted at all.
+ */
+export function SequentialCollapsedRowsProvider({
+ children,
+ collapsedStepIds,
+}: SequentialCollapsedRowsProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Returns the current collapsed row-id set; empty outside a provider (e.g. in isolated node tests/stories). */
+export function useSequentialCollapsedRows(): ReadonlySet {
+ return useContext(SequentialCollapsedRowsContext);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx
new file mode 100644
index 000000000..b1ffb4f0c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.test.tsx
@@ -0,0 +1,254 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { SequentialGutterRow } from './SequentialGutter';
+import { SequentialGutter } from './SequentialGutter';
+
+function makeRow(overrides: Partial = {}): SequentialGutterRow {
+ return {
+ nodeId: 'step-1',
+ stepNumber: 1,
+ collapsible: false,
+ collapsed: false,
+ ...overrides,
+ };
+}
+
+describe('SequentialGutter', () => {
+ it('renders nothing when there are no rows', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders the step number for every visible numbered row, right-aligned to its bar position', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1 }), makeRow({ nodeId: 'b', stepNumber: 2 })];
+ const positions = new Map([
+ ['a', { x: 0, y: 0 }],
+ ['b', { x: 0, y: 120 }],
+ ]);
+ render(
+
+ );
+
+ const numbers = screen.getAllByTestId('sequential-gutter-number');
+ expect(numbers.map((el) => el.textContent)).toEqual(['1', '2']);
+ });
+
+ it('skips a row with no known position instead of throwing', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })];
+ render(
+
+ );
+ expect(screen.queryByTestId('sequential-gutter-number')).not.toBeInTheDocument();
+ });
+
+ it('renders a chevron only for collapsible rows', () => {
+ const rows = [
+ makeRow({ nodeId: 'a', stepNumber: 1, collapsible: false }),
+ makeRow({ nodeId: 'b', stepNumber: 2, collapsible: true }),
+ ];
+ const positions = new Map([
+ ['a', { x: 0, y: 0 }],
+ ['b', { x: 0, y: 120 }],
+ ]);
+ render(
+
+ );
+
+ // Only one row is collapsible, so exactly one disclosure button renders.
+ expect(screen.getAllByRole('button')).toHaveLength(1);
+ });
+
+ it('sets aria-expanded=true and an expand-vs-collapse label from collapsed state', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true, collapsed: false })];
+ const positions = new Map([['a', { x: 64, y: 0 }]]);
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: 'Collapse step 3' });
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('reflects a collapsed row as aria-expanded=false with an expand label', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true, collapsed: true })];
+ const positions = new Map([['a', { x: 64, y: 0 }]]);
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: 'Expand step 3' });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('toggles collapse on click, flipping the current collapsed state', () => {
+ const onToggleCollapse = vi.fn();
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1, collapsible: true, collapsed: false })];
+ const positions = new Map([['a', { x: 64, y: 0 }]]);
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse step 1' }));
+ expect(onToggleCollapse).toHaveBeenCalledWith('a', true);
+ });
+
+ it('marks the number span aria-hidden since the row aria-label lives on the bar itself', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })];
+ const positions = new Map([['a', { x: 0, y: 0 }]]);
+ render(
+
+ );
+ expect(screen.getByTestId('sequential-gutter-number')).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ // The tree-rail rework. All numbers must sit in ONE fixed
+ // column regardless of a row's own indent depth, with a dotted leader line
+ // bridging out to each row's own (depth-varying) bar position.
+ describe('tree-rail geometry', () => {
+ it("positions every row column at the SAME fixed x, regardless of the row's own depth", () => {
+ const rows = [
+ makeRow({ nodeId: 'a', stepNumber: 1 }),
+ makeRow({ nodeId: 'b', stepNumber: 2 }),
+ ];
+ const positions = new Map([
+ ['a', { x: 0, y: 0 }], // depth 0
+ ['b', { x: 128, y: 120 }], // depth 2 (2 * SEQ_INDENT_PX)
+ ]);
+ render(
+
+ );
+
+ const columns = screen.getAllByTestId('sequential-gutter-row');
+ const lefts = columns.map((el) => el.style.left);
+ expect(lefts[0]).toBe(lefts[1]);
+ });
+
+ it('keeps the chevron immediately next to its number inside that same fixed column', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 3, collapsible: true })];
+ const positions = new Map([['a', { x: 128, y: 0 }]]);
+ render(
+
+ );
+
+ const column = screen.getByTestId('sequential-gutter-row');
+ expect(column).toContainElement(screen.getByRole('button'));
+ expect(column).toContainElement(screen.getByTestId('sequential-gutter-number'));
+ });
+
+ it("renders one dotted leader line per row, from the fixed column out to that row's own bar position", () => {
+ const rows = [
+ makeRow({ nodeId: 'a', stepNumber: 1 }),
+ makeRow({ nodeId: 'b', stepNumber: 2 }),
+ ];
+ const positions = new Map([
+ ['a', { x: 0, y: 0 }],
+ ['b', { x: 128, y: 120 }],
+ ]);
+ render(
+
+ );
+
+ const leaders = screen.getAllByTestId('sequential-gutter-leader');
+ expect(leaders).toHaveLength(2);
+ expect(leaders[0].className).toContain('border-dotted');
+ expect(leaders[0]).toHaveAttribute('aria-hidden', 'true');
+
+ // The deeper row's leader must span further right (it bridges a wider gap
+ // from the shared column to its own more-indented bar).
+ const widthA = Number.parseFloat(leaders[0].style.width);
+ const widthB = Number.parseFloat(leaders[1].style.width);
+ expect(widthB).toBeGreaterThan(widthA);
+ });
+
+ it('vertically centers the leader line on its row', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })];
+ const positions = new Map([['a', { x: 64, y: 100 }]]);
+ render(
+
+ );
+
+ const leader = screen.getByTestId('sequential-gutter-leader');
+ // top = row.y + SEQ_BAR_HEIGHT / 2 = 100 + 28.
+ expect(Number.parseFloat(leader.style.top)).toBe(128);
+ });
+
+ it('uses a custom bar height for the number row and leader center', () => {
+ const rows = [makeRow({ nodeId: 'a', stepNumber: 1 })];
+ const positions = new Map([['a', { x: 64, y: 100 }]]);
+ render(
+
+ );
+
+ expect(screen.getByTestId('sequential-gutter-row').style.height).toBe('48px');
+ expect(screen.getByTestId('sequential-gutter-leader').style.top).toBe('124px');
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx
new file mode 100644
index 000000000..16d0fa870
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialGutter.tsx
@@ -0,0 +1,165 @@
+import type { XYPosition } from '@uipath/apollo-react/canvas/xyflow/react';
+import { ViewportPortal } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Fragment, memo } from 'react';
+import { useSafeLingui } from '../../../i18n';
+import { SEQ_BAR_HEIGHT } from '../../constants';
+import type { SequenceRow } from '../../utils/sequential/sequential.types';
+import { CanvasInlineButton } from '../ButtonHandle/CanvasInlineButton';
+
+/**
+ * Width of the fixed step-number / chevron column. Every
+ * row's column sits at the SAME x regardless of its own indent depth -- a
+ * file-explorer "tree rail" rather than a per-row indented gutter -- so this
+ * only needs to be wide enough for a chevron (28px) + gap + a couple of digits
+ * of step number, not tied to `SEQ_INDENT_PX`.
+ */
+const SEQ_GUTTER_COLUMN_WIDTH_PX = 64;
+
+/**
+ * Horizontal breathing room (px) between the fixed column and where a row's
+ * dotted leader line begins. Also the leader's length for a depth-0 row (whose
+ * bar sits at the stack's own left edge), so every row -- even top-level ones
+ * -- gets a short, deliberate connector instead of the column touching the bar.
+ */
+const SEQ_GUTTER_LEADER_GAP_PX = 16;
+
+export interface SequentialGutterRow
+ extends Pick {}
+
+export interface SequentialGutterProps {
+ /**
+ * Visible rows carrying a step number, in row order (the same filter the
+ * canvas assembly derives once and shares with `useSequentialKeyboard`).
+ */
+ rows: readonly SequentialGutterRow[];
+ /**
+ * Row id -> top-left flow position, read straight from `layoutSequence`'s
+ * result (never from node DOM/measurement), so offscreen culling or a
+ * virtualization change upstream can never desync the gutter from the bars
+ * it labels; it also means the gutter pans/zooms with the canvas for free
+ * since it renders inside the same `ViewportPortal` coordinate space.
+ */
+ positions: ReadonlyMap;
+ /** Height of each sequential row; defaults to the standard bar height. */
+ barHeight?: number;
+ collapsedStepIds: ReadonlySet;
+ /** Fired by a collapsible row's chevron; the caller re-derives `collapsedStepIds`. */
+ onToggleCollapse: (nodeId: string, collapsed: boolean) => void;
+}
+
+/**
+ * Step-number "tree rail" gutter for the Sequential Canvas (D8). A single `ViewportPortal` layer
+ * (mirrors `components/NodeViewportOverlay.tsx`'s pattern, generalized to many
+ * rows instead of one node) rendering, per visible numbered row:
+ *
+ * - the step number and (for collapsible rows) a chevron disclosure button,
+ * right-aligned together in ONE fixed-x column shared by EVERY row
+ * regardless of its own indent depth (`SEQ_GUTTER_COLUMN_WIDTH_PX`, sitting
+ * `SEQ_GUTTER_LEADER_GAP_PX` left of the stack's own left edge) -- a
+ * file-explorer style numbering rail rather than a per-row indented gutter;
+ * - a dotted horizontal leader line from that fixed column to the LEFT EDGE
+ * of the row's own (depth-indented) bar, so a deeper row's leader is
+ * visibly longer, communicating depth the same way a file explorer's
+ * connecting lines do.
+ *
+ * The chevron carries `aria-expanded` (ARIA disclosure pattern) and a
+ * localized `aria-label` -- the row's own accessible name ("Step N of Total:
+ * Label") lives on the bar itself via `node.ariaLabel`, stamped by
+ * `useSequentialGraph` (D8), not here; the number span is `aria-hidden` so it
+ * isn't announced twice. The leader line is purely decorative (`aria-hidden`).
+ */
+export const SequentialGutter = memo(function SequentialGutter({
+ rows,
+ positions,
+ barHeight = SEQ_BAR_HEIGHT,
+ collapsedStepIds,
+ onToggleCollapse,
+}: SequentialGutterProps) {
+ const { _ } = useSafeLingui();
+
+ if (rows.length === 0) return null;
+
+ // The stack's own left edge: `layoutSequence` always anchors depth-0 rows at
+ // x = 0 (utils/sequential/layoutSequence.ts, read-only reference), but this
+ // reads it from the actual positions rather than assuming it, so a future
+ // layout change (e.g. a reference chip rendered further left) can't silently
+ // desync the rail from the bars it labels.
+ let stackLeftX = 0;
+ for (const position of positions.values()) {
+ if (position.x < stackLeftX) stackLeftX = position.x;
+ }
+ const columnLeftX = stackLeftX - SEQ_GUTTER_COLUMN_WIDTH_PX - SEQ_GUTTER_LEADER_GAP_PX;
+ const leaderStartX = stackLeftX - SEQ_GUTTER_LEADER_GAP_PX;
+
+ return (
+
+
+ {rows.map((row) => {
+ const position = positions.get(row.nodeId);
+ if (!position || row.stepNumber === undefined) return null;
+ const collapsed = collapsedStepIds.has(row.nodeId);
+ const rowCenterY = position.y + barHeight / 2;
+
+ return (
+
+
+ {row.collapsible && (
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ onToggleCollapse(row.nodeId, !collapsed);
+ }}
+ />
+ )}
+
+ {row.stepNumber}
+
+
+
+
+ );
+ })}
+
+
+ );
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx
new file mode 100644
index 000000000..effb6e3df
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertGapContext.tsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from 'react';
+import type { InsertionSlot } from '../../utils/sequential/sequential.types';
+
+export type SetSequentialInsertGapSlot = (slot: InsertionSlot) => void;
+
+const NOOP_SET: SetSequentialInsertGapSlot = () => {};
+
+const SequentialInsertGapContext = createContext(NOOP_SET);
+
+/** Provided by `SequentialCanvas.tsx`, which owns the actual state + the auto-clear-on-close effect. */
+export const SequentialInsertGapProvider = SequentialInsertGapContext.Provider;
+
+/**
+ * The setter every `useSequentialInsert()` call site's `startInsert` invokes
+ * to report the slot an Add Node panel just opened for -- both
+ * `SequentialCanvas.tsx`'s own terminal-placeholder callsite and every
+ * `SequentialConnectorEdge`'s "+" button are independent hook instances with
+ * no state of their own, so the slot needs a SHARED sink for the insert
+ * -preview -gap post-pass to react regardless of which
+ * affordance started the insert. `SequentialCanvas.tsx` owns clearing it
+ * (via `usePreviewNode` transitioning closed, covering both cancel and
+ * commit). A no-op default keeps isolated edge stories/tests (no
+ * `SequentialCanvas` mounted) working unchanged.
+ */
+export function useSetSequentialInsertGapSlot(): SetSequentialInsertGapSlot {
+ return useContext(SequentialInsertGapContext);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx
new file mode 100644
index 000000000..f0948df1d
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialInsertStateContext.tsx
@@ -0,0 +1,22 @@
+import { createContext, type MutableRefObject, type ReactNode, useContext, useRef } from 'react';
+import type { PendingSequentialInsert } from './edges/sequentialInsert';
+
+interface SequentialInsertState {
+ pending: MutableRefObject;
+}
+
+const SequentialInsertStateContext = createContext(undefined);
+
+/** Per-canvas insertion state shared by connector buttons and AddNodeManager. */
+export function SequentialInsertStateProvider({ children }: { children: ReactNode }) {
+ const pending = useRef(undefined);
+ return (
+
+ {children}
+
+ );
+}
+
+export function useOptionalSequentialInsertState(): SequentialInsertState | undefined {
+ return useContext(SequentialInsertStateContext);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx
new file mode 100644
index 000000000..b580290c9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialMoveActionsContext.tsx
@@ -0,0 +1,35 @@
+import { createContext, useContext } from 'react';
+import type { InsertionSlot } from '../../utils/sequential/sequential.types';
+import type { SequentialMoveOptions } from './sequentialMoveActions';
+
+export interface SequentialMoveActionsContextValue {
+ /** The four move candidates for `nodeId` (disabled direction => `undefined`). */
+ getMoveOptions: (nodeId: string) => SequentialMoveOptions;
+ /**
+ * Applies `moveSubtree(projection, nodeId, slot, {nodes, edges})` through
+ * `onNodesChange`/`onEdgesChange` (D10: the public API stays the standard
+ * change callbacks, never a parallel mutation channel). A no-op for a
+ * degenerate/self-targeting slot, matching `moveSubtree`'s own guard
+ * (an empty `GraphChangeSet`).
+ */
+ commitMove: (nodeId: string, slot: InsertionSlot) => void;
+ /** Centers the viewport on a node (used by the `goto` reference chip, D9). */
+ centerOnNode: (nodeId: string) => void;
+}
+
+const SequentialMoveActionsContext = createContext(
+ undefined
+);
+
+/** Provided by `SequentialCanvas.tsx`. */
+export const SequentialMoveActionsProvider = SequentialMoveActionsContext.Provider;
+
+/**
+ * Returns the current move-actions binding, or `undefined` outside a provider
+ * (isolated node/edge stories and tests render `SequentialStepNode` /
+ * `SequentialConnectorEdge` standalone; both must degrade gracefully -- no
+ * kebab move items, no reference-chip navigation -- rather than throw).
+ */
+export function useOptionalSequentialMoveActions(): SequentialMoveActionsContextValue | undefined {
+ return useContext(SequentialMoveActionsContext);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx
new file mode 100644
index 000000000..bcd67846a
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/SequentialViewContext.tsx
@@ -0,0 +1,71 @@
+import type { Viewport } from '@uipath/apollo-react/canvas/xyflow/react';
+import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef } from 'react';
+import type { CanvasView } from '../../utils/sequential/sequential.types';
+import { useCanvasViewMode } from './useCanvasViewMode';
+
+/**
+ * Coordination hub for a flow/sequential toggle composition. It owns the current
+ * view (persisted via {@link useCanvasViewMode}) and a per-view viewport store so
+ * switching views restores the viewport you left behind (the HierarchicalCanvas
+ * save/restore pattern applied to the view axis, D11).
+ *
+ * It is OPTIONAL: SequentialCanvas works standalone without it. A consumer that
+ * wants a real toggle wraps the canvas in {@link SequentialViewProvider} and
+ * feeds `view` / `onChange` to the canvas and ViewSwitcher.
+ */
+export interface SequentialViewContextValue {
+ view: CanvasView;
+ setView: (view: CanvasView) => void;
+ /** Remember a view's viewport before switching away. */
+ saveViewport: (view: CanvasView, viewport: Viewport) => void;
+ /** The viewport to restore when a view mounts (undefined = fit fresh). */
+ getViewport: (view: CanvasView) => Viewport | undefined;
+}
+
+const SequentialViewContext = createContext(undefined);
+
+export interface SequentialViewProviderProps {
+ children: ReactNode;
+ /** localStorage key backing the persisted view choice. */
+ storageKey: string;
+ initialView?: CanvasView;
+}
+
+export function SequentialViewProvider({
+ children,
+ storageKey,
+ initialView = 'flow',
+}: SequentialViewProviderProps) {
+ const [view, setView] = useCanvasViewMode(storageKey, initialView);
+ const viewportByView = useRef>(new Map());
+
+ const saveViewport = useCallback((forView: CanvasView, viewport: Viewport) => {
+ viewportByView.current.set(forView, viewport);
+ }, []);
+
+ const getViewport = useCallback(
+ (forView: CanvasView): Viewport | undefined => viewportByView.current.get(forView),
+ []
+ );
+
+ const value = useMemo(
+ () => ({ view, setView, saveViewport, getViewport }),
+ [view, setView, saveViewport, getViewport]
+ );
+
+ return {children} ;
+}
+
+/** Returns the context or throws when used outside a {@link SequentialViewProvider}. */
+export function useSequentialView(): SequentialViewContextValue {
+ const context = useContext(SequentialViewContext);
+ if (!context) {
+ throw new Error('useSequentialView must be used within a SequentialViewProvider');
+ }
+ return context;
+}
+
+/** Returns the context or undefined, so SequentialCanvas can run standalone. */
+export function useOptionalSequentialView(): SequentialViewContextValue | undefined {
+ return useContext(SequentialViewContext);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx
new file mode 100644
index 000000000..e96de55cf
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ToggleHarness.tsx
@@ -0,0 +1,89 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useRef, useState } from 'react';
+import { BaseNode } from '../BaseNode/BaseNode';
+import { prepareCanvasViewTransition } from './prepareCanvasViewTransition';
+import { SequentialCanvas } from './SequentialCanvas';
+import { SequentialViewProvider, useSequentialView } from './SequentialViewContext';
+import { ViewSwitcher } from './ViewSwitcher';
+
+export interface ToggleHarnessProps {
+ initialNodes: Node[];
+ initialEdges: Edge[];
+ storageKey?: string;
+ mode?: 'design' | 'view' | 'readonly';
+}
+
+const FLOW_NODE_TYPES = { default: BaseNode };
+
+/**
+ * Minimal composition demonstrating the flow<->sequential toggle (D11): a single
+ * canonical nodes/edges state, a ViewSwitcher, and one mounted SequentialCanvas
+ * that swaps its derived arrays in place. On toggle back to flow, nodes inserted
+ * while in sequential view are normalized and the canonical graph receives a
+ * deterministic left-to-right layout through prepareCanvasViewTransition.
+ *
+ * Used by tests and stories; not part of the public barrel (D13).
+ */
+export function ToggleHarness({
+ initialNodes,
+ initialEdges,
+ storageKey = 'sequential-canvas.toggle-harness',
+ mode = 'design',
+}: ToggleHarnessProps) {
+ return (
+
+
+
+ );
+}
+
+function ToggleHarnessInner({
+ initialNodes,
+ initialEdges,
+ mode,
+}: Required>) {
+ const { view, setView } = useSequentialView();
+ const [nodes, setNodes] = useState(initialNodes);
+ const [edges, setEdges] = useState(initialEdges);
+
+ const edgesRef = useRef(edges);
+ edgesRef.current = edges;
+ const handleViewChange = useCallback(
+ (nextView: typeof view) => {
+ setNodes((current) => prepareCanvasViewTransition(nextView, current, edgesRef.current).nodes);
+ setView(nextView);
+ },
+ [setView]
+ );
+
+ const onNodesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setNodes((current) => applyNodeChanges(changes, current)),
+ []
+ );
+ const onEdgesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setEdges((current) => applyEdgeChanges(changes, current)),
+ []
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx
new file mode 100644
index 000000000..f461d2f09
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.test.tsx
@@ -0,0 +1,15 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ViewSwitcher } from './ViewSwitcher';
+
+describe('ViewSwitcher', () => {
+ it('renders both view options and calls onChange when the other is picked', () => {
+ const onChange = vi.fn();
+ render( );
+
+ expect(screen.getByRole('radio', { name: 'Flow' })).toBeInTheDocument();
+ const sequential = screen.getByRole('radio', { name: 'Sequential' });
+ fireEvent.click(sequential);
+ expect(onChange).toHaveBeenCalledWith('sequential');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx
new file mode 100644
index 000000000..27a837f3c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/ViewSwitcher.tsx
@@ -0,0 +1,44 @@
+import { ToggleGroup, ToggleGroupItem } from '@uipath/apollo-wind';
+import { memo } from 'react';
+import { useSafeLingui } from '../../../i18n';
+import { CanvasIcon } from '../../utils/icon-registry';
+import type { CanvasView } from '../../utils/sequential/sequential.types';
+import type { ViewSwitcherProps } from './SequentialCanvas.types';
+
+/**
+ * Segmented flow/sequential control (D11). It is a controlled component: the
+ * host owns `value` and `onChange` (typically wired to useCanvasViewMode /
+ * SequentialViewProvider).
+ */
+function ViewSwitcherComponent({ value, onChange }: ViewSwitcherProps) {
+ const { _ } = useSafeLingui();
+
+ const flowLabel = _({ id: 'sequential-canvas.view.flow', message: 'Flow' });
+ const sequentialLabel = _({ id: 'sequential-canvas.view.sequential', message: 'Sequential' });
+
+ return (
+
+ {
+ // Radix emits '' when the active item is re-pressed; keep the current
+ // view rather than clearing it (a segmented control is never empty).
+ if (next === 'flow' || next === 'sequential') onChange(next as CanvasView);
+ }}
+ aria-label={_({ id: 'sequential-canvas.view.label', message: 'Canvas view' })}
+ >
+
+
+ {flowLabel}
+
+
+
+ {sequentialLabel}
+
+
+
+ );
+}
+
+export const ViewSwitcher = memo(ViewSwitcherComponent);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx
new file mode 100644
index 000000000..c92032212
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { SequentialBranchHeader } from './SequentialBranchHeader';
+
+describe('SequentialBranchHeader', () => {
+ it('aligns an edge-styled label immediately above the branch target', () => {
+ render( );
+
+ const header = screen.getByTestId('sequential-branch-header');
+ expect(header).toHaveStyle({ transform: 'translate(128px, 238px) translateY(-100%)' });
+ const label = screen.getByText('False');
+ expect(label).toHaveClass('react-flow__edge-label');
+ expect(label.getAttribute('style')).toContain('var(--canvas-primary)');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx
new file mode 100644
index 000000000..d2c2ab506
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialBranchHeader.tsx
@@ -0,0 +1,37 @@
+import { EdgeLabelRenderer } from '@uipath/apollo-react/canvas/xyflow/react';
+import { EdgeLabelContent } from '../../Edges/shared/primitives';
+
+const BRANCH_HEADER_BOTTOM_GAP = 2;
+
+export interface SequentialBranchHeaderProps {
+ x: number;
+ targetTopY: number;
+ label: string;
+ selected?: boolean;
+}
+
+/**
+ * Introduces a branch with the shared edge-label treatment immediately above
+ * its first row. The header is owned by the branch's content area and therefore
+ * remains stable when connector routes change.
+ */
+export function SequentialBranchHeader({
+ x,
+ targetTopY,
+ label,
+ selected,
+}: SequentialBranchHeaderProps) {
+ const anchorY = targetTopY - BRANCH_HEADER_BOTTOM_GAP;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx
new file mode 100644
index 000000000..ec4cacc6a
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen } from '@testing-library/react';
+import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import type { BaseCanvasProps } from '../../BaseCanvas/BaseCanvas.types';
+import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider';
+import { getLeftEntryArrowTargetY, SequentialConnectorEdge } from './SequentialConnectorEdge';
+import type {
+ SequentialConnectorData,
+ SequentialConnectorEdgeProps,
+} from './SequentialConnectorEdge.types';
+
+const baseProps = {
+ id: 'e1',
+ source: 'a',
+ target: 'b',
+ sourceX: 0,
+ sourceY: 0,
+ sourcePosition: Position.Bottom,
+ targetX: 100,
+ targetY: 200,
+ targetPosition: Position.Top,
+} as unknown as SequentialConnectorEdgeProps;
+
+function renderEdge(data: SequentialConnectorData, mode: BaseCanvasProps['mode'] = 'design') {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('SequentialConnectorEdge', () => {
+ describe('goto kind', () => {
+ it('renders a dashed path', () => {
+ const { container } = renderEdge({ kind: 'goto' });
+
+ const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement;
+ expect(visiblePath.style.strokeDasharray).toBe('5,5');
+ });
+ });
+
+ describe('merge-back kind', () => {
+ it('renders a straight container continuation solid', () => {
+ const { container } = renderEdge({
+ kind: 'merge-back',
+ waypoints: [],
+ slot: { id: 'container-continuation' },
+ });
+
+ const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement;
+ expect(visiblePath.style.strokeDasharray).toBe('0');
+ expect(screen.getByRole('button', { name: 'Insert step' }).parentElement).toHaveStyle({
+ transform: 'translate(-50%, -50%) translate(100px, 180px)',
+ });
+ });
+
+ it('renders an elbowed branch rejoin solid', () => {
+ const { container } = renderEdge({
+ kind: 'merge-back',
+ waypoints: [
+ { x: 0, y: 100 },
+ { x: 100, y: 100 },
+ ],
+ });
+
+ const visiblePath = container.querySelector('.react-flow__edge-path') as SVGPathElement;
+ expect(visiblePath.style.strokeDasharray).toBe('0');
+ });
+ });
+
+ it('centers a left-entry arrow using the target node height', () => {
+ expect(getLeftEntryArrowTargetY(200, 999, 48)).toBe(224);
+ expect(getLeftEntryArrowTargetY(undefined, 200, undefined)).toBe(228);
+ });
+
+ describe('step kind insert affordance', () => {
+ it('renders the insert button when a slot is present and the canvas is in design mode', () => {
+ renderEdge({ kind: 'step', slot: { id: 's-1', graphEdgeId: 'e1' } }, 'design');
+ const button = screen.getByRole('button', { name: 'Insert step' });
+ expect(button).toBeInTheDocument();
+ // Faint at rest (discoverable without hover), full on hover/focus.
+ expect(button).toHaveClass(
+ 'opacity-40',
+ 'group-hover:opacity-100',
+ 'group-focus-within:opacity-100'
+ );
+ });
+
+ it('does not render the insert button outside design mode, even with a slot', () => {
+ renderEdge({ kind: 'step', slot: { id: 's-1', graphEdgeId: 'e1' } }, 'view');
+ expect(screen.queryByRole('button', { name: 'Insert step' })).not.toBeInTheDocument();
+ });
+
+ it('does not render the insert button in design mode without a slot', () => {
+ renderEdge({ kind: 'step' }, 'design');
+ expect(screen.queryByRole('button', { name: 'Insert step' })).not.toBeInTheDocument();
+ });
+ });
+
+ it('renders a branch-entry label above the row using the shared edge-label style', () => {
+ const { container } = renderEdge({ kind: 'branch-entry', label: 'True' });
+
+ expect(screen.getByTestId('sequential-branch-header')).toHaveTextContent('True');
+ expect(container.querySelector('.react-flow__edge-label')).toBeInTheDocument();
+ expect(container.querySelector('foreignObject')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx
new file mode 100644
index 000000000..fd8ac7f50
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.tsx
@@ -0,0 +1,285 @@
+import { Position, useStore } from '@uipath/apollo-react/canvas/xyflow/react';
+import { memo, useCallback, useState } from 'react';
+import { useSafeLingui } from '../../../../i18n';
+import { SEQ_BAR_HEIGHT, SEQ_EDGE_CORNER_RADIUS } from '../../../constants';
+import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider';
+import { areEdgePropsEqual } from '../../Edges/shared/areEdgePropsEqual';
+import { EMPTY_WAYPOINTS } from '../../Edges/shared/constants';
+import { useEdgeGeometry, useExecutionEdge } from '../../Edges/shared/hooks';
+import { EdgeArrow, EdgeLabel, EdgePath } from '../../Edges/shared/primitives';
+import { resolveEdgeColor } from '../../Edges/shared/resolveEdgeColor';
+import { SequentialBranchHeader } from './SequentialBranchHeader';
+import type { SequentialConnectorEdgeProps } from './SequentialConnectorEdge.types';
+import { SequentialInsertButton } from './SequentialInsertButton';
+import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle';
+import { useSequentialInsert } from './useSequentialInsert';
+
+/**
+ * Vertical offset (flow px) between a NON-branch connector's label pill and its
+ * centered ⊕ (the ⊕ owns the connector's true midpoint; the label is nudged up).
+ * Branch-entry connectors instead split the two onto separate segments of their
+ * elbow (label on the vertical spine, ⊕ on the horizontal jog) so they never
+ * crowd - see the branch placement below.
+ */
+const INSERT_BUTTON_LABEL_OFFSET_PX = 26;
+
+/**
+ * Sequential arrowhead placement. Flow-view edges translate the arrow
+ * ARROW_OFFSET px INTO the target node so the node paints over the tuck;
+ * sequential bars are opaque and connectors render behind them, so a big tuck is
+ * clipped. The tip is anchored to the target bar's real top edge (read from the
+ * store, see `targetTopY`); this 1px nudge lets it bite just into the bar so it
+ * reads as connected rather than leaving a hairline gap, with the body in the
+ * clear row gap above it so it stays fully visible without elevating the edge.
+ */
+/**
+ * Vertical offset (flow px) that anchors a merge-back connector's insert plus
+ * to an endpoint instead of the line midpoint. A merge-back's midpoint drifts
+ * onto whichever row sits alongside it (e.g. a collapsed child), colliding with
+ * that row's own add points; anchoring near an endpoint reads as "add right
+ * here" and never lands on an unrelated row.
+ */
+const MERGE_BACK_INSERT_OFFSET_PX = 20;
+
+const ARROW_TIP_AT_BAR_EDGE = { x: 0, y: 1 } as const;
+/** Same 1px bite, but into the LEFT face for branch-entry (mid-left) arrows. */
+const ARROW_TIP_AT_BAR_LEFT_EDGE = { x: 1, y: 0 } as const;
+
+export function getLeftEntryArrowTargetY(
+ targetTopY: number | undefined,
+ targetY: number,
+ targetHeight: number | undefined
+): number {
+ return (targetTopY ?? targetY) + (targetHeight ?? SEQ_BAR_HEIGHT) / 2;
+}
+
+/**
+ * Sequential-view connector edge. Composes the shared edge primitives
+ * (EdgePath / EdgeArrow / EdgeLabel) and renders behavior from `data`:
+ *
+ * - stroke style by kind (ordinary structural flow solid; irregular `goto`
+ * references and transient previews dashed) via {@link resolveConnectorStrokeStyle};
+ * - an edge-styled label above the first row of each labeled branch;
+ * - `data.waypoints` rendered verbatim (elbows supplied by layoutSequence; no
+ * router, no waypoint editing);
+ * - a statically centered ⊕ insert affordance, revealed on hover/focus and
+ * available only in design mode when `data.slot` is present.
+ *
+ * See ./SequentialConnectorEdge.types.ts for the field-by-field `data` contract
+ * the assembly builds. `data` is compared by reference (areEdgePropsEqual), so the assembly must
+ * keep it reference-stable across data-only changes (D12).
+ */
+export const SequentialConnectorEdge = memo(function SequentialConnectorEdge({
+ id,
+ selected,
+ source,
+ target,
+ sourceX,
+ sourceY,
+ sourcePosition = Position.Bottom,
+ targetX,
+ targetY,
+ targetPosition = Position.Top,
+ sourceHandleId,
+ targetHandleId,
+ style,
+ data,
+}: SequentialConnectorEdgeProps) {
+ const { _ } = useSafeLingui();
+ const isDesignMode = useBaseCanvasMode().mode === 'design';
+ const { startInsert } = useSequentialInsert();
+
+ // Real top-left corner of the target bar. The handle-derived `targetX/Y` sit
+ // slightly off the bar's painted edge, so anchoring the arrowhead to the
+ // node's absolute position lands the tip exactly on the bar — its top edge for
+ // a normal (Top) entry, its left edge at mid-height for a branch (Left) entry.
+ const targetTopY = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.y);
+ const targetLeftX = useStore((s) => s.nodeLookup.get(target)?.internals.positionAbsolute.x);
+ const targetHeight = useStore((s) => {
+ const targetNode = s.nodeLookup.get(target);
+ // Sequential rows publish their layout-owned height immediately. Prefer it
+ // over the measured value, which can trail a slider update by one render.
+ return targetNode?.height ?? targetNode?.measured?.height;
+ });
+ // Branch-entry connectors carry a Left target handle, so React Flow reports
+ // Position.Left here — the arrow then points right into the bar's mid-left.
+ const isLeftEntry = targetPosition === Position.Left;
+
+ const [isHovered, setIsHovered] = useState(false);
+ const onMouseEnter = useCallback(() => setIsHovered(true), []);
+ const onMouseLeave = useCallback(() => setIsHovered(false), []);
+
+ const kind = data?.kind ?? 'step';
+ const label = data?.label;
+ const slot = data?.slot;
+ const hideArrowHead = !!data?.hideArrowHead;
+ const isPreview = !!data?.preview;
+ const waypoints = data?.waypoints ?? EMPTY_WAYPOINTS;
+
+ // Waypoint routing is used for every kind: elbowed connectors carry explicit
+ // waypoints, and straight step connectors get a clean orthogonal path between
+ // the two anchors from an empty waypoint list.
+ const geometry = useEdgeGeometry({
+ routing: 'waypoint',
+ sourceNodeId: source,
+ targetNodeId: target,
+ sourceHandleId,
+ targetHandleId,
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ waypoints,
+ enableSegments: false,
+ hideArrowHead,
+ // Pronounced smooth-step corners for the sequential view: the elbowed
+ // branch/merge-back connectors read as soft rounded steps rather than the
+ // subtler default. createRoundedPath clamps this to half the shorter
+ // adjacent segment, so tight jogs degrade gracefully.
+ borderRadius: SEQ_EDGE_CORNER_RADIUS,
+ });
+
+ const execution = useExecutionEdge({
+ edgeId: id,
+ target,
+ edgePath: geometry.edgePath,
+ enabled: !!data?.enableExecution,
+ });
+
+ const color = resolveEdgeColor({
+ selected,
+ isHovered,
+ previewEdge: isPreview,
+ statusColor: execution.statusColor,
+ });
+
+ // Merge-backs are ordinary structural flow regardless of whether they are a
+ // straight container continuation or an elbowed branch rejoin. Only
+ // irregular goto references and transient previews use a dashed stroke.
+ const strokeStyle = isPreview ? 'dashed' : resolveConnectorStrokeStyle(kind);
+ const showInsert = isDesignMode && !!slot;
+ const hasLabel = typeof label === 'string' && label.length > 0;
+
+ // Branch-entry connectors elbow: a vertical spine drop from the owner then a
+ // horizontal jog into the target (waypoints [spineBottom, jogEnd] from
+ // layoutSequence). The ⊕ stays on that horizontal jog. Its label is rendered
+ // separately above the target row, so labels no longer
+ // compete with connector geometry or one another.
+ const wp0 = waypoints[0];
+ const wp1 = waypoints[1];
+ const isBranchElbow = kind === 'branch-entry' && wp0 !== undefined && wp1 !== undefined;
+ // Merge-back placement: a straight merge-back (a container's continuation,
+ // no waypoints) anchors just ABOVE its target, so "add after the container"
+ // sits after the whole body rather than in the middle of it. An elbowed
+ // merge-back (a branch lane rejoining the flow) anchors just BELOW its source,
+ // so "add at the end of this branch" sits under the branch's last step.
+ const insertPoint = isBranchElbow
+ ? { x: (wp0.x + wp1.x) / 2, y: wp0.y }
+ : kind === 'merge-back'
+ ? waypoints.length === 0
+ ? { x: targetX, y: targetY - MERGE_BACK_INSERT_OFFSET_PX }
+ : { x: sourceX, y: sourceY + MERGE_BACK_INSERT_OFFSET_PX }
+ : geometry.labelPoint;
+ const labelPoint = showInsert
+ ? { x: geometry.labelPoint.x, y: geometry.labelPoint.y - INSERT_BUTTON_LABEL_OFFSET_PX }
+ : geometry.labelPoint;
+ const resolvedTargetHeight = typeof targetHeight === 'number' ? targetHeight : SEQ_BAR_HEIGHT;
+ const resolvedTargetTopY =
+ typeof targetTopY === 'number'
+ ? targetTopY
+ : isLeftEntry
+ ? targetY - resolvedTargetHeight / 2
+ : targetY;
+ const resolvedTargetLeftX = typeof targetLeftX === 'number' ? targetLeftX : targetX;
+
+ const onInsert = useCallback(() => {
+ if (!slot) return;
+ startInsert({
+ slot,
+ source,
+ sourceHandleId,
+ target,
+ targetHandleId,
+ sourcePosition,
+ position: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
+ // This edge's OWN rendered id — the store in sequential view holds
+ // derived connector edges, never canonical ids, so the split connector
+ // must be found (and stashed for cancel-restore) by this id, not
+ // slot.graphEdgeId.
+ connectorEdgeId: id,
+ });
+ }, [
+ slot,
+ source,
+ sourceHandleId,
+ target,
+ targetHandleId,
+ sourcePosition,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ startInsert,
+ id,
+ ]);
+
+ const opacity = (style?.opacity as number | undefined) ?? 1;
+
+ return (
+ <>
+
+
+
+ {!hideArrowHead && (
+
+ )}
+
+ {execution.animation}
+
+ {hasLabel && kind !== 'branch-entry' && (
+
+ )}
+
+
+ {hasLabel && kind === 'branch-entry' && (
+
+ )}
+
+ {showInsert && (
+
+ )}
+ >
+ );
+}, areEdgePropsEqual);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts
new file mode 100644
index 000000000..9cb86bc2f
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialConnectorEdge.types.ts
@@ -0,0 +1,69 @@
+import type { Edge, EdgeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import type {
+ InsertionSlot,
+ SequenceConnectorKind,
+} from '../../../utils/sequential/sequential.types';
+import type { Waypoint } from '../../Edges/shared/types';
+
+/**
+ * Reference-stable `data` payload for a sequential connector edge.
+ *
+ * The assembly builds one of these per {@link SequenceConnector} when it derives the
+ * xyflow edge array (see useSequentialGraph). The object MUST be built only on
+ * structural change and reused by reference otherwise, so `areEdgePropsEqual`
+ * (which compares `data` by reference, see components/Edges/shared/areEdgePropsEqual.ts)
+ * can short-circuit the memo during pan/zoom and data-only edits (D12).
+ *
+ * Field-by-field contract:
+ * - `kind` copy from `connector.kind`. Drives stroke style: `step`
+ * `branch-entry`, and `merge-back` render solid; only
+ * irregular `goto` references render dashed. See
+ * {@link resolveConnectorStrokeStyle}.
+ * - `label` copy from `connector.label`. Branch-entry labels render
+ * above their first row using the shared edge-label style;
+ * other kinds
+ * retain the centered edge pill. Omit or use an empty
+ * string to render no label.
+ * - `waypoints` elbow geometry from `layoutSequence().connectorWaypoints`
+ * keyed by the connector id. Rendered verbatim (no router,
+ * no editing). When absent, the edge draws a clean
+ * orthogonal path between the two handle anchors. Reuse the
+ * SAME array reference across renders; a fresh `[]` each
+ * render defeats geometry memoization (prefer the shared
+ * EMPTY_WAYPOINTS fallback).
+ * - `slot` copy from `connector.slot`. The centered insert (+)
+ * button renders IFF this is present AND the canvas is in
+ * design mode. `goto` connectors carry no slot and so get
+ * no button.
+ * - `enableExecution` opt in to id-keyed execution coloring + the in-progress
+ * dot, via the existing ExecutionStatusContext. Defaults to
+ * off; turning it on matches CanvasEdge behavior.
+ * - `hideArrowHead` suppress the arrow head. Defaults to showing an arrow
+ * pointing into the target for every kind.
+ */
+// A `type` (not an `interface`) so it implicitly satisfies xyflow's
+// `Edge>` constraint, matching CanvasEdgeData.
+export type SequentialConnectorData = {
+ kind: SequenceConnectorKind;
+ label?: string | null;
+ waypoints?: Waypoint[];
+ slot?: InsertionSlot;
+ /** Transient Add Node connector: selected color + dashed preview stroke. */
+ preview?: boolean;
+ /** Visual-only preview join that AddNodeManager must not materialize. */
+ ignorePreviewConnection?: boolean;
+ /**
+ * Canonical manifest handle on the existing endpoint of a preview edge.
+ * The rendered edge stays on the guaranteed sequential bar handle while
+ * AddNodeManager uses this id for connection filtering and materialization.
+ */
+ previewConnectionHandleId?: string;
+ enableExecution?: boolean;
+ hideArrowHead?: boolean;
+};
+
+/** The xyflow edge shape rendered by {@link SequentialConnectorEdge}. */
+export type SequentialConnectorEdgeType = Edge;
+
+/** Props xyflow hands the edge component. */
+export type SequentialConnectorEdgeProps = EdgeProps;
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx
new file mode 100644
index 000000000..d7473bf24
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/SequentialInsertButton.tsx
@@ -0,0 +1,59 @@
+import { EdgeLabelRenderer, type XYPosition } from '@uipath/apollo-react/canvas/xyflow/react';
+import { type MouseEvent as ReactMouseEvent, useCallback } from 'react';
+import { CanvasInlineButton } from '../../ButtonHandle/CanvasInlineButton';
+
+export interface SequentialInsertButtonProps {
+ /** Center point in flow coordinates (the connector midpoint). */
+ point: XYPosition;
+ /** Accessible label, already localized. */
+ label: string;
+ /** Invoked on click; opens the Add Node panel for the connector's slot. */
+ onInsert: () => void;
+}
+
+/**
+ * Statically centered ⊕ affordance for a sequential connector (design mode
+ * only; the caller gates on slot presence + mode). It rests at a low opacity so
+ * every insertable connector shows a quiet ⊕, then brightens to full on
+ * hover/focus. This keeps add points discoverable without a wall of
+ * full-strength buttons when several nested slots are nearby, and pairs with the
+ * plus variant of SequentialPlaceholderNode so "add a step" looks identical
+ * everywhere. Unlike the
+ * hover-following EdgeToolbar (see Toolbar/EdgeToolbar/useEdgeToolbarPositioning.ts),
+ * this sits at a fixed point on the connector and never tracks the pointer.
+ *
+ * It stops mousedown/click propagation so opening the Add Node panel does not
+ * trip the Toolbox's outside-mousedown close (components/Toolbox/Toolbox.tsx:621-625).
+ */
+export function SequentialInsertButton({ point, label, onInsert }: SequentialInsertButtonProps) {
+ const stopMouseDown = useCallback((event: ReactMouseEvent) => {
+ event.stopPropagation();
+ }, []);
+
+ const handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ onInsert();
+ },
+ [onInsert]
+ );
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts
new file mode 100644
index 000000000..9443e7101
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/index.ts
@@ -0,0 +1,24 @@
+// Local barrel for the sequential connector + insert pipeline. This is NOT
+// re-exported from components/index.ts; the feature stays out of the public
+// canvas barrel until GA (D13). Types first, component/helpers after.
+
+export { SequentialBranchHeader } from './SequentialBranchHeader';
+export { SequentialConnectorEdge } from './SequentialConnectorEdge';
+export type {
+ SequentialConnectorData,
+ SequentialConnectorEdgeProps,
+ SequentialConnectorEdgeType,
+} from './SequentialConnectorEdge.types';
+export type { SequentialInsertButtonProps } from './SequentialInsertButton';
+export { SequentialInsertButton } from './SequentialInsertButton';
+export { resolveConnectorStrokeStyle } from './sequentialConnectorStyle';
+export type { SequentialInsertArgs } from './sequentialInsert';
+export {
+ buildSequentialPreviewOptions,
+ getSequentialIgnoredNodeTypes,
+ SEQ_INSERTED_FLAG,
+ SEQUENTIAL_IGNORED_NODE_TYPES,
+ sequentialOnBeforeNodeAdded,
+} from './sequentialInsert';
+export type { UseSequentialInsertResult } from './useSequentialInsert';
+export { useSequentialInsert } from './useSequentialInsert';
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts
new file mode 100644
index 000000000..4dc52748d
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types';
+import { resolveConnectorStrokeStyle } from './sequentialConnectorStyle';
+
+describe('resolveConnectorStrokeStyle', () => {
+ it('renders structural flow connectors solid', () => {
+ expect(resolveConnectorStrokeStyle('step')).toBe('solid');
+ expect(resolveConnectorStrokeStyle('branch-entry')).toBe('solid');
+ expect(resolveConnectorStrokeStyle('merge-back')).toBe('solid');
+ });
+
+ it('renders irregular references dashed', () => {
+ expect(resolveConnectorStrokeStyle('goto')).toBe('dashed');
+ });
+
+ it('covers every connector kind', () => {
+ const kinds: SequenceConnectorKind[] = ['step', 'branch-entry', 'merge-back', 'goto'];
+ for (const kind of kinds) {
+ expect(['solid', 'dashed']).toContain(resolveConnectorStrokeStyle(kind));
+ }
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts
new file mode 100644
index 000000000..b1ba9d2b6
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialConnectorStyle.ts
@@ -0,0 +1,15 @@
+import type { SequenceConnectorKind } from '../../../utils/sequential/sequential.types';
+import type { EdgeStrokeStyle } from '../../Edges/shared/types';
+
+/**
+ * Maps a connector kind to its stroke style.
+ *
+ * - `step` / `branch-entry` / `merge-back` are all structural control flow:
+ * solid.
+ * - `goto` is an irregular reference (cycle or already-visited target): dashed.
+ * - transient preview connectors are handled by SequentialConnectorEdge and
+ * remain dashed independently of this mapping.
+ */
+export function resolveConnectorStrokeStyle(kind: SequenceConnectorKind): EdgeStrokeStyle {
+ return kind === 'goto' ? 'dashed' : 'solid';
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts
new file mode 100644
index 000000000..267ee1a64
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.test.ts
@@ -0,0 +1,450 @@
+import type { Edge, Node, ReactFlowInstance } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ DEFAULT_SOURCE_HANDLE_ID,
+ DEFAULT_TARGET_HANDLE_ID,
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+ SEQ_PLACEHOLDER_NODE_TYPE,
+ SEQ_START_NODE_TYPE,
+} from '../../../constants';
+import { SEQ_CONTINUATION_EDGE_KEY } from '../../../utils/sequential/graph-helpers';
+import type { InsertionSlot } from '../../../utils/sequential/sequential.types';
+import { SEQUENTIAL_BAR_HANDLE_IDS } from '../nodes';
+import {
+ buildSequentialPreviewOptions,
+ getSequentialIgnoredNodeTypes,
+ type PendingSequentialInsert,
+ resolvePendingSequentialInsert,
+ SEQ_INSERTED_FLAG,
+ SEQUENTIAL_IGNORED_NODE_TYPES,
+ sequentialOnBeforeNodeAdded,
+} from './sequentialInsert';
+
+const FAKE_UUID = '00000000-0000-0000-0000-000000000001';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('SEQUENTIAL_IGNORED_NODE_TYPES', () => {
+ it('lists exactly the synthetic sequential row types (clones keep real types)', () => {
+ expect(SEQUENTIAL_IGNORED_NODE_TYPES).toEqual([SEQ_START_NODE_TYPE, SEQ_PLACEHOLDER_NODE_TYPE]);
+ });
+
+ it('merges host extras into a fresh array', () => {
+ const merged = getSequentialIgnoredNodeTypes(['sticky']);
+ expect(merged).toEqual([...SEQUENTIAL_IGNORED_NODE_TYPES, 'sticky']);
+ expect(merged).not.toBe(SEQUENTIAL_IGNORED_NODE_TYPES);
+ });
+});
+
+describe('sequentialOnBeforeNodeAdded', () => {
+ const previousId = 'uipath.slack-1720000000000';
+
+ const seedNode = (): Node =>
+ ({
+ id: previousId,
+ type: 'uipath.slack',
+ position: { x: 420, y: 96 },
+ data: { label: 'Slack', display: { label: 'Send message' } },
+ }) as Node;
+
+ const seedEdges = (): Edge[] => [
+ {
+ id: `edge_srcA-output-${previousId}-input`,
+ source: 'srcA',
+ sourceHandle: 'output',
+ target: previousId,
+ targetHandle: 'input',
+ type: 'default',
+ } as Edge,
+ {
+ id: `edge_${previousId}-output-tgtB-input`,
+ source: previousId,
+ sourceHandle: 'output',
+ target: 'tgtB',
+ targetHandle: 'input',
+ type: 'default',
+ } as Edge,
+ ];
+
+ it('re-ids the node with crypto.randomUUID()', () => {
+ const spy = vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges());
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(newNode.id).toBe(FAKE_UUID);
+ });
+
+ it('keeps the preview-derived position and does not force draggable false', () => {
+ // The sequential clone derivation (useSequentialGraph) already stamps
+ // draggable:false on every row unconditionally, so onBeforeNodeAdded must
+ // not also write it into canonical state, or it leaks permanently once the
+ // user toggles back to flow view (synthesizePositionsForFlow only clears it
+ // on that boundary; nothing clears it while still in sequential view).
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const seed = seedNode();
+ const { newNode } = sequentialOnBeforeNodeAdded(seed, seedEdges());
+ expect(newNode.position).toEqual(seed.position);
+ expect(newNode.draggable).toBeUndefined();
+ });
+
+ it('stamps seqInserted while preserving existing data and type', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges());
+ expect(newNode.type).toBe('uipath.slack');
+ expect((newNode.data as Record)[SEQ_INSERTED_FLAG]).toBe(true);
+ expect((newNode.data as Record).label).toBe('Slack');
+ });
+
+ it('rewrites edges that reference the seed id and regenerates their ids', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges());
+
+ const incoming = newEdges.find((edge) => edge.source === 'srcA');
+ const outgoing = newEdges.find((edge) => edge.target === 'tgtB');
+
+ expect(incoming?.target).toBe(FAKE_UUID);
+ expect(incoming?.id).toBe(`edge_srcA-output-${FAKE_UUID}-input`);
+ expect(outgoing?.source).toBe(FAKE_UUID);
+ expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-output-tgtB-input`);
+ });
+
+ it('leaves an unrelated edge untouched by reference', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const unrelated = {
+ id: 'edge_x-output-y-input',
+ source: 'x',
+ target: 'y',
+ type: 'default',
+ } as Edge;
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), [unrelated]);
+ expect(newEdges[0]).toBe(unrelated);
+ });
+
+ describe('with a pending slot (canonical handle/containment rewriting)', () => {
+ const barEdges = (): Edge[] => [
+ {
+ id: `edge_srcA-${SEQUENTIAL_BAR_HANDLE_IDS.source}-${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.target}`,
+ source: 'srcA',
+ sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ target: previousId,
+ targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: 'default',
+ } as Edge,
+ {
+ id: `edge_${previousId}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-${SEQUENTIAL_BAR_HANDLE_IDS.target}`,
+ source: previousId,
+ sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ target: 'tgtB',
+ targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: 'default',
+ } as Edge,
+ ];
+
+ it('restores only the original nodes canonical handles', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const pending: PendingSequentialInsert = {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ sourceHandleId: 'true',
+ targetHandleId: 'input',
+ };
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending);
+
+ const incoming = newEdges.find((edge) => edge.source === 'srcA');
+ const outgoing = newEdges.find((edge) => edge.target === 'tgtB');
+
+ expect(incoming?.sourceHandle).toBe('true');
+ expect(outgoing?.targetHandle).toBe('input');
+ // The inserted node's own handles are not replaced with handles that
+ // belong to the original source/target nodes.
+ expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target);
+ expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source);
+ });
+
+ it('restores implicit defaults only on the original node endpoints', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ });
+
+ const incoming = newEdges.find((edge) => edge.source === 'srcA');
+ const outgoing = newEdges.find((edge) => edge.target === 'tgtB');
+
+ expect(incoming?.sourceHandle).toBeUndefined();
+ expect(outgoing?.targetHandle).toBeUndefined();
+ expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.target);
+ expect(outgoing?.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source);
+ });
+
+ it('regenerates the edge id from the rewritten handles so the id matches the fields', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const pending: PendingSequentialInsert = {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ sourceHandleId: 'true',
+ targetHandleId: 'input',
+ };
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), barEdges(), pending);
+ const outgoing = newEdges.find((edge) => edge.source === FAKE_UUID);
+ expect(outgoing?.id).toBe(`edge_${FAKE_UUID}-${SEQUENTIAL_BAR_HANDLE_IDS.source}-tgtB-input`);
+ });
+
+ it('tags both split halves with the exact canonical edge id', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ graphEdgeId: 'canonical-edge-7',
+ sourceHandleId: 'output',
+ targetHandleId: 'input',
+ });
+
+ expect(newEdges).toHaveLength(2);
+ for (const edge of newEdges) {
+ expect((edge.data as Record).__sequentialSplitEdgeId).toBe(
+ 'canonical-edge-7'
+ );
+ }
+ });
+
+ it('marks the inserted-node-to-downstream half as a continuation', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ graphEdgeId: 'canonical-edge-7',
+ });
+
+ const incoming = newEdges.find((edge) => edge.target === FAKE_UUID);
+ const outgoing = newEdges.find((edge) => edge.source === FAKE_UUID);
+ expect(
+ (incoming?.data as Record)[SEQ_CONTINUATION_EDGE_KEY]
+ ).toBeUndefined();
+ expect((outgoing?.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true);
+ });
+
+ it('preserves an existing continuation marker on the source half when splitting it again', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ graphEdgeId: 'canonical-edge-7',
+ splitEdgeWasContinuation: true,
+ });
+
+ for (const edge of newEdges) {
+ expect((edge.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true);
+ }
+ });
+
+ it('leaves non-bar handle ids untouched even when a pending value is present', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const pending: PendingSequentialInsert = {
+ sourceNodeId: 'srcA',
+ targetNodeId: 'tgtB',
+ sourceHandleId: 'ignored',
+ targetHandleId: 'ignored',
+ };
+ const { newEdges } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), pending);
+ const incoming = newEdges.find((edge) => edge.source === 'srcA');
+ const outgoing = newEdges.find((edge) => edge.target === 'tgtB');
+ expect(incoming?.sourceHandle).toBe('ignored');
+ expect(outgoing?.targetHandle).toBe('ignored');
+ expect(incoming?.targetHandle).toBe('input');
+ expect(outgoing?.sourceHandle).toBe('output');
+ });
+
+ it('applies the slot containerId as canonical parentId/extent on the materialized node', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {
+ containerId: 'loop-1',
+ });
+ expect(newNode.parentId).toBe('loop-1');
+ expect(newNode.extent).toBe('parent');
+ });
+
+ it('omits parentId/extent when the slot has no container', () => {
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue(FAKE_UUID);
+ const { newNode } = sequentialOnBeforeNodeAdded(seedNode(), seedEdges(), {});
+ expect(newNode.parentId).toBeUndefined();
+ expect(newNode.extent).toBeUndefined();
+ });
+ });
+});
+
+describe('resolvePendingSequentialInsert', () => {
+ it('captures the slot canonical handle ids and containerId', () => {
+ const slot: InsertionSlot = {
+ id: 'slot-1',
+ source: { nodeId: 'a', handleId: 'true' },
+ target: { nodeId: 'b', handleId: 'input' },
+ containerId: 'loop-1',
+ };
+ expect(resolvePendingSequentialInsert(slot)).toEqual({
+ sourceNodeId: 'a',
+ targetNodeId: 'b',
+ graphEdgeId: undefined,
+ sourceHandleId: 'true',
+ targetHandleId: 'input',
+ containerId: 'loop-1',
+ splitEdgeWasContinuation: undefined,
+ });
+ });
+
+ it('captures continuation semantics when inserting into an already-healed spine', () => {
+ const pending = resolvePendingSequentialInsert({
+ id: 'slot-1',
+ source: { nodeId: 'a' },
+ target: { nodeId: 'b' },
+ continuation: true,
+ });
+ expect(pending.splitEdgeWasContinuation).toBe(true);
+ });
+
+ it('yields undefined handle ids (never a bar handle) when the slot endpoint omits one', () => {
+ const slot: InsertionSlot = { id: 'slot-1', source: { nodeId: 'a' }, target: { nodeId: 'b' } };
+ const pending = resolvePendingSequentialInsert(slot);
+ expect(pending.sourceHandleId).toBeUndefined();
+ expect(pending.targetHandleId).toBeUndefined();
+ });
+
+ it('yields no containerId when the slot is not scoped to a container', () => {
+ const slot: InsertionSlot = { id: 'slot-1' };
+ expect(resolvePendingSequentialInsert(slot).containerId).toBeUndefined();
+ });
+});
+
+describe('buildSequentialPreviewOptions', () => {
+ // The connector's rendered id deliberately DIFFERS from the canonical edge id
+ // it wraps (as the projection's `conn:${edge.id}` scheme actually produces), proving the
+ // lookup uses connectorEdgeId (the rendered id) and not slot.graphEdgeId (the
+ // canonical id) — those live in different id spaces in sequential view, since
+ // the store here only ever holds derived connector edges.
+ const connectorEdge = {
+ id: 'conn:e-1',
+ source: 'row-a',
+ sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ target: 'row-b',
+ targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: 'sequentialConnector',
+ } as Edge;
+
+ const makeInstance = (edges: Edge[] = [connectorEdge]) =>
+ ({ getEdges: () => edges }) as unknown as ReactFlowInstance;
+
+ const baseArgs = {
+ source: 'a',
+ sourceHandleId: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ target: 'b',
+ targetHandleId: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ sourcePosition: Position.Bottom,
+ position: { x: 100, y: 200 },
+ };
+
+ it('rides the existing pipeline with bar-sized preview + ignored sequential types', () => {
+ const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' };
+ const options = buildSequentialPreviewOptions(makeInstance(), {
+ ...baseArgs,
+ slot,
+ connectorEdgeId: 'conn:e-1',
+ });
+
+ expect(options.previewNodeSize).toEqual({ width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT });
+ expect(options.ignoredNodeTypes).toBe(SEQUENTIAL_IGNORED_NODE_TYPES);
+ expect(options.positionMode).toBe('drop');
+ expect(options.sourceHandleType).toBe('source');
+ expect(options.handlePosition).toBe(Position.Bottom);
+ expect(options.position).toEqual({ x: 100, y: 200 });
+ });
+
+ it('anchors preview edges on the slot canonical handle ids so connection filtering can resolve them', () => {
+ const slot: InsertionSlot = {
+ id: 'slot-1',
+ source: { nodeId: 'container', handleId: 'true' },
+ target: { nodeId: 'b', handleId: 'input' },
+ };
+ const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot });
+ // Node id and handle id both come from the slot: bars now render an
+ // invisible handle per manifest handle id (BaseNodeBar
+ // manifestSource/TargetHandleIds), so the preview edge renders AND
+ // usePreviewNode can resolve the existing node's handle manifest for the
+ // Add Node connection filter (bar handle ids never appeared in the manifest).
+ expect(options.source).toEqual({ nodeId: 'container', handleId: 'true' });
+ expect(options.target).toEqual({ nodeId: 'b', handleId: 'input' });
+ });
+
+ it('falls back to the default handle ids when the slot omits them (tail append)', () => {
+ const slot: InsertionSlot = { id: 'slot-tail', source: { nodeId: 'last' } };
+ const options = buildSequentialPreviewOptions(makeInstance([]), {
+ ...baseArgs,
+ slot,
+ target: '',
+ targetHandleId: undefined,
+ });
+ expect(options.source).toEqual({ nodeId: 'last', handleId: DEFAULT_SOURCE_HANDLE_ID });
+ expect('target' in options).toBe(false);
+ });
+
+ it('drives a head insertion from the first canonical target without using the synthetic start row', () => {
+ const slot: InsertionSlot = { id: 'slot-head', target: { nodeId: 'first' } };
+ const options = buildSequentialPreviewOptions(makeInstance(), {
+ ...baseArgs,
+ source: '__sequential-start__',
+ target: 'first',
+ slot,
+ });
+
+ expect(options.source).toEqual({ nodeId: 'first', handleId: DEFAULT_TARGET_HANDLE_ID });
+ expect(options.sourceHandleType).toBe('target');
+ expect(options.handlePosition).toBe(Position.Top);
+ expect('target' in options).toBe(false);
+ });
+
+ it('stashes the split CONNECTOR edge (found by its own rendered id) on data.originalEdge', () => {
+ const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' };
+ const options = buildSequentialPreviewOptions(makeInstance(), {
+ ...baseArgs,
+ slot,
+ connectorEdgeId: 'conn:e-1',
+ });
+ expect(options.data).toEqual({ originalEdge: connectorEdge });
+ });
+
+ it('never finds an edge via slot.graphEdgeId alone (different id space)', () => {
+ // Regression: slot.graphEdgeId ('e-1') is the CANONICAL edge id; the store
+ // here only holds the derived connector edge ('conn:e-1'). Omitting
+ // connectorEdgeId must not accidentally match by falling back to graphEdgeId.
+ const slot: InsertionSlot = { id: 'slot-1', graphEdgeId: 'e-1' };
+ const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot });
+ expect(options.data).toEqual({});
+ });
+
+ it('omits originalEdge when the insert has no backing connector (tail append)', () => {
+ const slot: InsertionSlot = { id: 'slot-1' };
+ const options = buildSequentialPreviewOptions(makeInstance(), { ...baseArgs, slot });
+ expect(options.data).toEqual({});
+ });
+
+ it('never forwards slot.containerId into the preview graph options', () => {
+ // createPreviewGraph would reparent the preview onto the container's
+ // rendered clone with extent:'parent'; in sequential view that clone is a
+ // flattened bar, so the preview would clamp onto the container's own row.
+ // Canonical containment is applied to the materialized node instead (see
+ // resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded).
+ const withContainer = buildSequentialPreviewOptions(makeInstance(), {
+ ...baseArgs,
+ slot: { id: 'slot-1', containerId: 'loop-1' },
+ });
+ expect('containerId' in withContainer).toBe(false);
+
+ const withoutContainer = buildSequentialPreviewOptions(makeInstance(), {
+ ...baseArgs,
+ slot: { id: 'slot-1' },
+ });
+ expect('containerId' in withoutContainer).toBe(false);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts
new file mode 100644
index 000000000..e725a4d77
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/sequentialInsert.ts
@@ -0,0 +1,309 @@
+import type {
+ Edge,
+ Node,
+ ReactFlowInstance,
+ XYPosition,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import {
+ DEFAULT_SOURCE_HANDLE_ID,
+ DEFAULT_TARGET_HANDLE_ID,
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+ SEQ_PLACEHOLDER_NODE_TYPE,
+ SEQ_START_NODE_TYPE,
+} from '../../../constants';
+import type { CreatePreviewGraphOptions } from '../../../utils/createPreviewGraph';
+import { SEQ_CONTINUATION_EDGE_KEY } from '../../../utils/sequential/graph-helpers';
+import type { InsertionSlot } from '../../../utils/sequential/sequential.types';
+
+/**
+ * Node `type` values excluded from the Add Node collision passes
+ * (shiftForEdgeInsertion / resolveCollisions, utils/NodeUtils.ts) so they never
+ * reposition the layout-owned vertical stack (D5).
+ *
+ * IMPORTANT: this covers ONLY the synthetic rows. Real step clones KEEP their
+ * manifest node type, so `ignoredNodeTypes` cannot enumerate them — the
+ * robust guarantee that layout owns clone positions lives in the change
+ * filtering, which drops every position/dimension change for clones before
+ * forwarding onNodesChange to the consumer (seam 2). Listing the synthetic types
+ * here is defense in depth so the collision pass can't nudge the start bar or
+ * terminal placeholder either.
+ *
+ * The names are re-exported from constants.ts, the single source of truth shared
+ * with the node components (nodes/index.ts).
+ */
+export const SEQUENTIAL_IGNORED_NODE_TYPES: string[] = [
+ SEQ_START_NODE_TYPE,
+ SEQ_PLACEHOLDER_NODE_TYPE,
+];
+
+/** Returns a fresh ignored-types array, optionally merged with host-specific extras. */
+export function getSequentialIgnoredNodeTypes(extra: readonly string[] = []): string[] {
+ return [...SEQUENTIAL_IGNORED_NODE_TYPES, ...extra];
+}
+
+/** Marker written on nodes inserted while in sequential view (D4). */
+export const SEQ_INSERTED_FLAG = 'seqInserted';
+
+/**
+ * Marker stashed on a forwarded edge's `data` carrying the exact canonical edge
+ * id that an insert split, so `forwardSequentialEdgeChanges` can drop that edge
+ * precisely instead of inferring it. Stripped before the edge reaches the host.
+ */
+export const SEQ_SPLIT_EDGE_ID_KEY = '__sequentialSplitEdgeId';
+
+/** Arguments the ⊕ button gives the insert wiring. */
+export interface SequentialInsertArgs {
+ /** The slot carried by the clicked connector. */
+ slot: InsertionSlot;
+ /** Fallback source endpoint (the connector's own source), used when the slot omits one. */
+ source: string;
+ /**
+ * The connector's own rendered source handle (always one of
+ * the sequential bar's view-only handles). No longer consulted for handle-id
+ * resolution (see {@link buildSequentialPreviewOptions} and
+ * {@link resolvePendingSequentialInsert}) — propagating it into canonical
+ * state is exactly the bug those functions guard against. Kept on the arg
+ * shape for call-site stability.
+ */
+ sourceHandleId?: string | null;
+ /** Fallback target endpoint (the connector's own target), used when the slot omits one. */
+ target: string;
+ /** The connector's own rendered target handle. See {@link sourceHandleId}. */
+ targetHandleId?: string | null;
+ /** Side of the source node the preview edge leaves from (bar bottom). */
+ sourcePosition: Position;
+ /** Drop point for the preview, in flow coordinates (the connector midpoint). */
+ position: XYPosition;
+ /**
+ * The id of the connector edge AS RENDERED (the `id` xyflow gives
+ * {@link SequentialConnectorEdge}), used to find and stash the split
+ * connector for cancel-restore. Undefined for slots with no backing edge
+ * (a tail append, or an empty branch/container body).
+ */
+ connectorEdgeId?: string;
+}
+
+/**
+ * Builds the {@link CreatePreviewGraphOptions} for a sequential insertion,
+ * riding the existing Add Node preview pipeline verbatim (D5). Splits into a
+ * pure builder so the option shape is unit-testable without a live canvas.
+ *
+ * The preview node is sized to a bar (SEQ_BAR_WIDTH x SEQ_BAR_HEIGHT) so the
+ * stack visibly opens a slot. These options retain canonical manifest handles
+ * as the preview is created. When the sequential projection rebuilds those
+ * edges, `buildSequentialEdges` renders them on the guaranteed bar handles and
+ * carries the canonical existing-endpoint handle separately for Add Node
+ * filtering/materialization. This keeps preview geometry reliable without
+ * losing connection semantics. The exact slot endpoints are also captured by
+ * {@link resolvePendingSequentialInsert} and restored by
+ * {@link sequentialOnBeforeNodeAdded}.
+ *
+ * The split connector (found by its OWN rendered id, `connectorEdgeId` — the
+ * store in sequential view holds derived connector edges, never canonical
+ * ids) is stashed on `data.originalEdge` so AddNodeManager can hide it while
+ * the panel is open and restore it if the panel is cancelled.
+ */
+export function buildSequentialPreviewOptions(
+ reactFlowInstance: ReactFlowInstance,
+ args: SequentialInsertArgs
+): CreatePreviewGraphOptions {
+ const { slot, source, target, sourcePosition, position, connectorEdgeId } = args;
+
+ const originalEdge = connectorEdgeId
+ ? reactFlowInstance.getEdges().find((edge) => edge.id === connectorEdgeId)
+ : undefined;
+
+ // Any target-only slot has no canonical source node. Drive the same preview
+ // pipeline from its canonical target handle; sourceHandleType='target' makes
+ // the preview the upstream source. The Workflow-start connector is one user
+ // of this general slot shape, without making the synthetic start bar part of
+ // canonical state or registry constraint checks.
+ const targetEndpoint = slot.target;
+ const isTargetOnlyInsert = slot.source === undefined && targetEndpoint !== undefined;
+ const previewSourceNodeId = isTargetOnlyInsert
+ ? targetEndpoint.nodeId
+ : (slot.source?.nodeId ?? source);
+ const previewSourceHandleId = isTargetOnlyInsert
+ ? (targetEndpoint.handleId ?? DEFAULT_TARGET_HANDLE_ID)
+ : (slot.source?.handleId ?? DEFAULT_SOURCE_HANDLE_ID);
+
+ // The target is optional: a tail append (the terminal placeholder) has a source
+ // but no downstream node. When neither the slot nor the fallback supplies a
+ // target node id, omit `target` entirely so the pipeline builds a source-only
+ // preview instead of wiring an edge to an empty id.
+ const targetNodeId = isTargetOnlyInsert ? undefined : (slot.target?.nodeId ?? target);
+
+ return {
+ reactFlowInstance,
+ source: {
+ nodeId: previewSourceNodeId,
+ // Preserve the canonical handle in the semantic preview graph. The
+ // derived sequential edge remaps its visual anchor to the bar handle and
+ // carries this id as previewConnectionHandleId for registry filtering.
+ handleId: previewSourceHandleId,
+ },
+ ...(targetNodeId
+ ? {
+ target: {
+ nodeId: targetNodeId,
+ handleId: slot.target?.handleId ?? DEFAULT_TARGET_HANDLE_ID,
+ },
+ }
+ : {}),
+ position,
+ positionMode: 'drop',
+ data: originalEdge ? { originalEdge } : {},
+ sourceHandleType: isTargetOnlyInsert ? 'target' : 'source',
+ handlePosition: isTargetOnlyInsert ? Position.Top : sourcePosition,
+ ignoredNodeTypes: SEQUENTIAL_IGNORED_NODE_TYPES,
+ previewNodeSize: { width: SEQ_BAR_WIDTH, height: SEQ_BAR_HEIGHT },
+ // containerId is intentionally NOT forwarded here: createPreviewGraph would
+ // reparent the preview onto the container's RENDERED clone with
+ // extent:'parent', but that clone is a flattened 896x72 bar (sequential
+ // view has no flow-view container body), so the preview would clamp to
+ // exactly overlap the container's row instead of showing in the lane. The
+ // canonical containment the slot describes is applied to the MATERIALIZED
+ // node instead — see resolvePendingSequentialInsert / sequentialOnBeforeNodeAdded.
+ };
+}
+
+/** Canonical containment/handle data resolved from a slot at insert time (D5). */
+export interface PendingSequentialInsert {
+ sourceNodeId?: string;
+ targetNodeId?: string;
+ graphEdgeId?: string;
+ /** slot.source's canonical handle id, if the split edge had an explicit one. */
+ sourceHandleId?: string;
+ /** slot.target's canonical handle id, if the split edge had an explicit one. */
+ targetHandleId?: string;
+ /** parentId for the materialized node, if the slot is scoped to a container. */
+ containerId?: string;
+ /** Whether the split source edge already represented an explicit continuation. */
+ splitEdgeWasContinuation?: boolean;
+}
+
+/**
+ * Captures the slot's canonical handle/containment data at the moment
+ * `startInsert` opens the panel, for {@link sequentialOnBeforeNodeAdded} to
+ * apply once the user picks a node (D5). Handle ids fall back to `undefined`
+ * (default-handle semantics), never to the connector's own bar handle — the
+ * bar handle is a view-rendering detail (see buildSequentialPreviewOptions)
+ * that must never reach canonical state.
+ */
+export function resolvePendingSequentialInsert(slot: InsertionSlot): PendingSequentialInsert {
+ return {
+ sourceNodeId: slot.source?.nodeId,
+ targetNodeId: slot.target?.nodeId,
+ graphEdgeId: slot.graphEdgeId,
+ sourceHandleId: slot.source?.handleId,
+ targetHandleId: slot.target?.handleId,
+ containerId: slot.containerId,
+ splitEdgeWasContinuation: slot.continuation,
+ };
+}
+
+/**
+ * Adapter for `AddNodeManager.onBeforeNodeAdded` (D4/D5). It:
+ * 1. keeps the preview's position as a harmless placement hint — layout owns
+ * positions in sequential view and the projection recomputes them on the
+ * next structural change (D12), so any value here is transient; reusing
+ * the preview's position (instead of zeroing it) just avoids piling the
+ * node onto the head of the stack before the collision pass runs;
+ * 2. stamps `data.seqInserted = true` so `synthesizePositionsForFlow` can
+ * place the node when the user toggles back to flow view (D4). Does NOT
+ * stamp `draggable: false` — the sequential clone derivation already forces
+ * that unconditionally for every row (useSequentialGraph), so doing it here
+ * too would only leak into canonical state and permanently disable dragging
+ * once the user toggles back to flow view;
+ * 3. re-ids the node with `crypto.randomUUID()` and rewrites the new edges to
+ * reference the new id (the pipeline seeds a `type-Date.now()` id, which can
+ * collide within the same millisecond);
+ * 4. applies the slot's canonical containment (`parentId`/`extent`) and
+ * restores the two existing-node endpoints to the slot's exact canonical
+ * handles. The inserted node's endpoints are deliberately left alone:
+ * AddNodeManager resolves those from the inserted node's own manifest.
+ * `pending` is captured by `useSequentialInsert` from the slot at the
+ * moment the panel opened (see resolvePendingSequentialInsert);
+ * 5. marks the inserted-node-to-existing-target edge as the sequence
+ * continuation. This keeps the old downstream sequence at the insertion
+ * scope instead of treating it as a branch/body child of the new node.
+ */
+export function sequentialOnBeforeNodeAdded(
+ newNode: Node,
+ newEdges: Edge[],
+ pending?: PendingSequentialInsert
+): { newNode: Node; newEdges: Edge[] } {
+ const seqId = crypto.randomUUID();
+ const previousId = newNode.id;
+
+ const finalNode: Node = {
+ ...newNode,
+ id: seqId,
+ ...(pending?.containerId ? { parentId: pending.containerId, extent: 'parent' as const } : {}),
+ data: { ...newNode.data, [SEQ_INSERTED_FLAG]: true },
+ };
+
+ const finalEdges = newEdges.map((edge) => {
+ const nextSource = edge.source === previousId ? seqId : edge.source;
+ const nextTarget = edge.target === previousId ? seqId : edge.target;
+ let nextSourceHandle = edge.sourceHandle;
+ let nextTargetHandle = edge.targetHandle;
+ // Preview edges use concrete handles so registry filtering works, while
+ // the split canonical edge may have relied on implicit defaults. Restore
+ // only the EXISTING nodes' exact endpoint semantics before forwarding;
+ // the inserted node's handles already came from its own manifest.
+ if (
+ pending?.sourceNodeId &&
+ edge.source === pending.sourceNodeId &&
+ edge.target === previousId
+ ) {
+ nextSourceHandle = pending.sourceHandleId;
+ }
+ if (
+ pending?.targetNodeId &&
+ edge.source === previousId &&
+ edge.target === pending.targetNodeId
+ ) {
+ nextTargetHandle = pending.targetHandleId;
+ }
+ const preservesSplitContinuation =
+ pending?.splitEdgeWasContinuation === true &&
+ edge.source === pending.sourceNodeId &&
+ edge.target === previousId;
+ const keepsDownstreamOutsideInsertedNode =
+ pending?.targetNodeId !== undefined &&
+ edge.source === previousId &&
+ edge.target === pending.targetNodeId;
+ const carriesContinuation = preservesSplitContinuation || keepsDownstreamOutsideInsertedNode;
+ const data =
+ pending?.graphEdgeId || carriesContinuation
+ ? {
+ ...edge.data,
+ ...(pending?.graphEdgeId ? { [SEQ_SPLIT_EDGE_ID_KEY]: pending.graphEdgeId } : {}),
+ ...(carriesContinuation ? { [SEQ_CONTINUATION_EDGE_KEY]: true } : {}),
+ }
+ : edge.data;
+ if (
+ nextSource === edge.source &&
+ nextTarget === edge.target &&
+ nextSourceHandle === edge.sourceHandle &&
+ nextTargetHandle === edge.targetHandle &&
+ data === edge.data
+ ) {
+ return edge;
+ }
+ return {
+ ...edge,
+ source: nextSource,
+ target: nextTarget,
+ sourceHandle: nextSourceHandle,
+ targetHandle: nextTargetHandle,
+ data,
+ id: `edge_${nextSource}-${nextSourceHandle}-${nextTarget}-${nextTargetHandle}`,
+ };
+ });
+
+ return { newNode: finalNode, newEdges: finalEdges };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx
new file mode 100644
index 000000000..2fb095ac3
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.test.tsx
@@ -0,0 +1,106 @@
+import { renderHook } from '@testing-library/react';
+import type { Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import type { InsertionSlot } from '../../../utils/sequential/sequential.types';
+import { SequentialInsertGapProvider } from '../SequentialInsertGapContext';
+import { SequentialInsertStateProvider } from '../SequentialInsertStateContext';
+import { useSequentialInsert } from './useSequentialInsert';
+
+// The preview pipeline needs a live RF store; this hook test only exercises the
+// pending-slot bookkeeping, so stub the side effect out.
+vi.mock('../../../utils/createPreviewGraph', async (importOriginal) => ({
+ ...(await importOriginal()),
+ showPreviewGraph: vi.fn(),
+}));
+
+describe('useSequentialInsert', () => {
+ it('publishes the clicked slot so the placeholder is swapped for the preview row', () => {
+ const setGapSlot = vi.fn();
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ const hook = renderHook(() => useSequentialInsert(), { wrapper });
+ const slot: InsertionSlot = { id: 'tail', source: { nodeId: 'last-step' } };
+
+ hook.result.current.startInsert({
+ slot,
+ source: 'last-step',
+ target: '',
+ sourcePosition: Position.Bottom,
+ position: { x: 0, y: 72 },
+ });
+
+ expect(setGapSlot).toHaveBeenCalledWith(slot);
+ });
+
+ it('shares the pending slot across separate hook instances so container parenting survives commit', () => {
+ // Regression: the connector plus (startInsert) and AddNodeManager's
+ // onBeforeNodeAdded are DIFFERENT useSequentialInsert instances. A
+ // per-instance ref left the commit side blind to the slot's containerId, so
+ // inserts into a For Each body / branch lane dropped parentId and orphaned
+ // the node. Both hook instances share one per-canvas provider.
+ const hooks = renderHook(
+ () => ({ opener: useSequentialInsert(), committer: useSequentialInsert() }),
+ { wrapper: SequentialInsertStateProvider }
+ );
+
+ const slot: InsertionSlot = {
+ id: 'slot-then',
+ source: { nodeId: 'if', handleId: 'true' },
+ target: { nodeId: 'javascript-1', handleId: 'input' },
+ graphEdgeId: 'e-if-then',
+ containerId: 'for-each',
+ };
+
+ hooks.result.current.opener.startInsert({
+ slot,
+ source: 'if',
+ target: 'javascript-1',
+ sourcePosition: Position.Bottom,
+ position: { x: 0, y: 0 },
+ });
+
+ const previewNode: Node = {
+ id: 'preview-node-id',
+ type: 'uipath.blank-node',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const { newNode } = hooks.result.current.committer.onBeforeNodeAdded(previewNode, []);
+
+ // The other instance's commit sees the opener's slot: container parent is
+ // stamped so the node splices into the lane rather than orphaning at the end.
+ expect(newNode.parentId).toBe('for-each');
+ expect(newNode.extent).toBe('parent');
+ });
+
+ it('isolates pending inserts between canvas providers', () => {
+ const canvasA = renderHook(() => useSequentialInsert(), {
+ wrapper: SequentialInsertStateProvider,
+ });
+ const canvasB = renderHook(() => useSequentialInsert(), {
+ wrapper: SequentialInsertStateProvider,
+ });
+ canvasA.result.current.startInsert({
+ slot: { id: 'a', source: { nodeId: 'a' }, containerId: 'container-a' },
+ source: 'a',
+ target: '',
+ sourcePosition: Position.Bottom,
+ position: { x: 0, y: 0 },
+ });
+ canvasB.result.current.startInsert({
+ slot: { id: 'b', source: { nodeId: 'b' }, containerId: 'container-b' },
+ source: 'b',
+ target: '',
+ sourcePosition: Position.Bottom,
+ position: { x: 0, y: 0 },
+ });
+ const seed: Node = { id: 'preview-node-id', position: { x: 0, y: 0 }, data: {} };
+ expect(canvasA.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-a');
+ expect(canvasB.result.current.onBeforeNodeAdded(seed, []).newNode.parentId).toBe('container-b');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts
new file mode 100644
index 000000000..e21208c76
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/edges/useSequentialInsert.ts
@@ -0,0 +1,68 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useRef } from 'react';
+import { showPreviewGraph } from '../../../utils/createPreviewGraph';
+import { useSetSequentialInsertGapSlot } from '../SequentialInsertGapContext';
+import { useOptionalSequentialInsertState } from '../SequentialInsertStateContext';
+import {
+ buildSequentialPreviewOptions,
+ resolvePendingSequentialInsert,
+ type SequentialInsertArgs,
+ sequentialOnBeforeNodeAdded,
+} from './sequentialInsert';
+
+export interface UseSequentialInsertResult {
+ /**
+ * Opens the Add Node panel for a connector's insertion slot by riding the
+ * existing preview pipeline (D5). Call this from the ⊕ button's click handler.
+ */
+ startInsert: (args: SequentialInsertArgs) => void;
+ /**
+ * Bound `AddNodeManager.onBeforeNodeAdded` adapter. Closes over the slot
+ * captured by the most recent `startInsert` call so materialization can
+ * apply the slot's canonical handle ids / containment (D5) — `onBeforeNodeAdded`
+ * itself only receives the new node and edges, not the slot that started the
+ * insert, so this hook is the side channel between the two.
+ */
+ onBeforeNodeAdded: (newNode: Node, newEdges: Edge[]) => { newNode: Node; newEdges: Edge[] };
+}
+
+/**
+ * Wires the ⊕ insert affordance to the existing Add Node preview pipeline. It
+ * only kicks off the preview; the panel, constraint filtering, materialization,
+ * and cancel-restore all run through the unchanged AddNodeManager (which the
+ * assembly mounts as a child of the canvas with `onBeforeNodeAdded` from this hook and
+ * `ignoredNodeTypes` from ./sequentialInsert).
+ */
+export function useSequentialInsert(): UseSequentialInsertResult {
+ const reactFlowInstance = useReactFlow();
+ const setInsertGapSlot = useSetSequentialInsertGapSlot();
+ const localPending = useRef | undefined>(
+ undefined
+ );
+ const sharedState = useOptionalSequentialInsertState();
+ const pending = sharedState?.pending ?? localPending;
+
+ const startInsert = useCallback(
+ (args: SequentialInsertArgs) => {
+ pending.current = resolvePendingSequentialInsert(args.slot);
+ // Surfaces the active slot for the insert-preview-gap post-pass:
+ // SequentialCanvas.tsx owns the state and clears it again
+ // on cancel/commit (usePreviewNode transitioning closed).
+ setInsertGapSlot(args.slot);
+ showPreviewGraph(buildSequentialPreviewOptions(reactFlowInstance, args));
+ },
+ [reactFlowInstance, setInsertGapSlot, pending]
+ );
+
+ const onBeforeNodeAdded = useCallback(
+ (newNode: Node, newEdges: Edge[]) => {
+ const result = sequentialOnBeforeNodeAdded(newNode, newEdges, pending.current);
+ pending.current = undefined;
+ return result;
+ },
+ [pending]
+ );
+
+ return { startInsert, onBeforeNodeAdded };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts
new file mode 100644
index 000000000..b0793f856
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/index.ts
@@ -0,0 +1,23 @@
+export type {
+ CanvasViewTransitionResult,
+ PrepareCanvasViewTransitionOptions,
+} from './prepareCanvasViewTransition';
+export { prepareCanvasViewTransition } from './prepareCanvasViewTransition';
+export { SequentialCanvas } from './SequentialCanvas';
+export type { SequentialCanvasProps, ViewSwitcherProps } from './SequentialCanvas.types';
+export type {
+ SequentialViewContextValue,
+ SequentialViewProviderProps,
+} from './SequentialViewContext';
+export { SequentialViewProvider, useSequentialView } from './SequentialViewContext';
+export { synthesizePositionsForFlow } from './synthesizePositionsForFlow';
+export { useCanvasViewMode } from './useCanvasViewMode';
+export type { SequentialGraph, UseSequentialGraphArgs } from './useSequentialGraph';
+export { deriveSequentialGraph, useSequentialGraph } from './useSequentialGraph';
+export type {
+ SequentialKeyboardRow,
+ UseSequentialKeyboardArgs,
+ UseSequentialKeyboardResult,
+} from './useSequentialKeyboard';
+export { useSequentialKeyboard } from './useSequentialKeyboard';
+export { ViewSwitcher } from './ViewSwitcher';
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx
new file mode 100644
index 000000000..3dba72d9e
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/BarVariant.stories.tsx
@@ -0,0 +1,123 @@
+/**
+ * Sequential bar variant, visual-drift contract.
+ *
+ * Renders the same representative manifests twice: as free-form cards (left) and
+ * as sequential bars (right), plus the synthetic start and placeholder rows. The
+ * bar reuses BaseNode's resolved display, so icon, label, and colors match the
+ * card for every node. This is an early seed of the fuller card/bar contract.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import type { Node, NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react';
+import { ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useMemo } from 'react';
+import { useNodeTypeRegistry } from '../../../core';
+import {
+ createNode,
+ useNodeTypesFromRegistry,
+ withCanvasProviders,
+} from '../../../storybook-utils';
+import { BaseCanvas } from '../../BaseCanvas';
+import { BaseNode } from '../../BaseNode/BaseNode';
+import {
+ SEQ_PLACEHOLDER_NODE_TYPE,
+ SEQ_START_NODE_TYPE,
+ SEQUENTIAL_SYNTHETIC_NODE_TYPES,
+ SequentialStepNode,
+} from './index';
+
+const meta: Meta = {
+ title: 'Components/Nodes/Sequential Bar',
+ parameters: { layout: 'fullscreen' },
+ decorators: [withCanvasProviders()],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Representative manifests: a trigger, a branch, a container, a data step, and an
+// agent. All exist in the default workflow manifest installed by the decorator.
+const REPRESENTATIVE_TYPES = [
+ 'uipath.manual-trigger',
+ 'uipath.control-flow.decision',
+ 'uipath.control-flow.foreach',
+ 'uipath.data.transform',
+ 'uipath.agent',
+] as const;
+
+const CARD_PITCH = 176;
+const BAR_PITCH = 128;
+
+function cardNodes(): Node[] {
+ return REPRESENTATIVE_TYPES.map((type, i) =>
+ createNode({ id: `card-${i}`, type, position: { x: 0, y: i * CARD_PITCH } })
+ );
+}
+
+function barNodes(): Node[] {
+ const start: Node = {
+ id: 'seq-start',
+ type: SEQ_START_NODE_TYPE,
+ position: { x: 0, y: 0 },
+ data: { onAddTrigger: () => {} },
+ draggable: false,
+ };
+ const steps = REPRESENTATIVE_TYPES.map((type, i) =>
+ createNode({ id: `bar-${i}`, type, position: { x: 0, y: (i + 1) * BAR_PITCH } })
+ );
+ const placeholder: Node = {
+ id: 'seq-placeholder',
+ type: SEQ_PLACEHOLDER_NODE_TYPE,
+ position: { x: 0, y: (REPRESENTATIVE_TYPES.length + 1) * BAR_PITCH },
+ data: { onAdd: () => {} },
+ draggable: false,
+ };
+ return [start, ...steps, placeholder];
+}
+
+function BarVariantStory() {
+ const registry = useNodeTypeRegistry();
+ const cardTypes = useNodeTypesFromRegistry(BaseNode);
+ const barTypes = useMemo(() => {
+ const types: NodeTypes = { default: SequentialStepNode, ...SEQUENTIAL_SYNTHETIC_NODE_TYPES };
+ for (const manifest of registry.getAllManifests()) {
+ types[manifest.nodeType] = SequentialStepNode;
+ }
+ return types;
+ }, [registry]);
+
+ const cards = useMemo(cardNodes, []);
+ const bars = useMemo(barNodes, []);
+
+ return (
+
+ );
+}
+
+export const CardVsBar: Story = {
+ name: 'Card vs Bar',
+ render: () => ,
+};
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx
new file mode 100644
index 000000000..e5958a88c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialInsertPreviewNode.tsx
@@ -0,0 +1,75 @@
+import type { NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { cn } from '@uipath/apollo-wind';
+import { memo, useMemo } from 'react';
+import { useSafeLingui } from '../../../../i18n';
+import { getIcon } from '../../../utils/icon-registry';
+import {
+ getSeqBarVars,
+ INVISIBLE_HANDLE_STYLE,
+ SEQ_BAR_SHELL_CLASS,
+ SEQUENTIAL_BAR_HANDLE_IDS,
+} from '../../BaseNode/BaseNodeBar';
+import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape';
+
+/**
+ * Sequential-view Add Node preview bar. The shared {@link AddNodePreview}
+ * centers a `min(w,h)`-sized glyph, which in the 896x56 bar leaves a nearly
+ * empty slab with a lonely icon. This mirrors the real bar layout instead: a
+ * dashed, de-emphasized bar with the icon box on the LEFT and a "New step"
+ * label, so the slot the insert will fill reads like a ghost of a real row.
+ *
+ * Registered as the `preview` node type only in the sequential view (see
+ * SequentialCanvas.tsx); the flow view keeps AddNodePreview. Its handle ids
+ * (`input`/`output`) match the Add Node preview edges, offset to the connector
+ * spine (SEQ handle offset) so those edges align like a real bar's connectors.
+ */
+function SequentialInsertPreviewNodeComponent({ width, height }: NodeProps) {
+ const { _ } = useSafeLingui();
+ const label = _({ id: 'sequential-canvas.new-step', message: 'New step' });
+ const icon = useMemo(() => {
+ const IconComponent = getIcon('square-dashed');
+ return ;
+ }, []);
+
+ return (
+
+
+
+ {icon}
+ {label}
+
+
+ );
+}
+
+export const SequentialInsertPreviewNode = memo(SequentialInsertPreviewNodeComponent);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx
new file mode 100644
index 000000000..5576d7ccd
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '../../../utils/testing';
+import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider';
+import { SequentialPlaceholderNode } from './SequentialPlaceholderNode';
+
+// The node renders xyflow s, which need a store provider. This test
+// only exercises the affordance rendering, so stub them out.
+vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, Handle: () => null };
+});
+
+// Minimal NodeProps stand-in for a focused render test.
+// biome-ignore lint/suspicious/noExplicitAny: minimal NodeProps stub.
+const props = (data: object) => ({ id: 'ph', data, width: 896, height: 44 }) as any;
+
+function renderNode(data: object, mode: 'design' | 'view' = 'design') {
+ return render(
+
+
+
+ );
+}
+
+describe('SequentialPlaceholderNode', () => {
+ it('renders the dashed Add step row for the row variant (empty lane/body)', () => {
+ renderNode({ variant: 'row', onAdd: () => {} });
+ expect(screen.getByTestId('sequential-placeholder-bar')).toBeInTheDocument();
+ // The row shows a visible label.
+ expect(screen.getByText('Add step')).toBeInTheDocument();
+ expect(screen.queryByTestId('sequential-placeholder-plus')).not.toBeInTheDocument();
+ });
+
+ it('renders a quiet plus (no visible label) for the plus variant (append)', () => {
+ renderNode({ variant: 'plus', onAdd: () => {} });
+ expect(screen.getByTestId('sequential-placeholder-plus')).toBeInTheDocument();
+ // The plus is icon-only: labelled for a11y, but no visible text.
+ expect(screen.getByRole('button', { name: 'Add step' })).toBeInTheDocument();
+ expect(screen.queryByText('Add step')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('sequential-placeholder-bar')).not.toBeInTheDocument();
+ });
+
+ it('defaults to the row variant when none is given', () => {
+ renderNode({ onAdd: () => {} });
+ expect(screen.getByTestId('sequential-placeholder-bar')).toBeInTheDocument();
+ });
+
+ it('calls onAdd when the plus is clicked in design mode', () => {
+ const onAdd = vi.fn();
+ renderNode({ variant: 'plus', onAdd });
+ fireEvent.click(screen.getByRole('button', { name: 'Add step' }));
+ expect(onAdd).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not add outside design mode', () => {
+ const onAdd = vi.fn();
+ renderNode({ variant: 'plus', onAdd }, 'view');
+ fireEvent.click(screen.getByRole('button', { name: 'Add step' }));
+ expect(onAdd).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx
new file mode 100644
index 000000000..28f45c5f8
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialPlaceholderNode.tsx
@@ -0,0 +1,140 @@
+import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { cn } from '@uipath/apollo-wind';
+import { memo } from 'react';
+import { useSafeLingui } from '../../../../i18n';
+import { SEQ_HANDLE_LEFT_OFFSET } from '../../../constants';
+import { CanvasIcon } from '../../../utils/icon-registry';
+import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider';
+import {
+ getSeqBarVars,
+ INVISIBLE_HANDLE_STYLE,
+ SEQ_BAR_SHELL_CLASS,
+ SEQUENTIAL_BAR_HANDLE_IDS,
+} from '../../BaseNode/BaseNodeBar';
+import { CanvasInlineButton } from '../../ButtonHandle/CanvasInlineButton';
+
+/**
+ * The faint-until-hover treatment shared by every sequential insert affordance
+ * (the connector plus in SequentialInsertButton and this node's plus variant),
+ * so "add a step" reads and behaves identically wherever it appears.
+ */
+const SEQ_INSERT_REST_CLASS =
+ 'opacity-40 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100';
+
+export interface SequentialPlaceholderNodeData extends Record {
+ /** Invoked when the affordance is clicked to add a step into this scope. */
+ onAdd?: () => void;
+ /** View-only slot identity used to recover this row's position when clicked. */
+ insertionSlotId?: string;
+ /**
+ * Which affordance to render (see `SequenceRow.placeholderKind`):
+ * - 'row' (default): a dashed "Add step" bar for a genuinely empty lane/body.
+ * - 'plus': a quiet plus matching the between-step insert, for the trailing
+ * add point after a populated lane's last step (or the tail).
+ */
+ variant?: 'row' | 'plus';
+}
+
+/**
+ * Synthetic "add a step" row. Two shapes, chosen by `data.variant`:
+ * - 'row': a dashed, bar-sized drop target with a centered plus and label,
+ * styled after AddNodePreview. Used where a lane or body is genuinely empty
+ * and there is no connector to host a plus.
+ * - 'plus': the same quiet plus used between steps, anchored on the incoming
+ * connector, so appending at the end of a list looks identical to inserting
+ * between two steps.
+ *
+ * It carries a top target handle (the tail follows the last step) and a mid-left
+ * handle (an empty lane is entered from the side). Injected view-only and
+ * filtered out of the change callbacks by the canvas assembly.
+ */
+function SequentialPlaceholderNodeComponent({
+ data,
+ width,
+ height,
+}: NodeProps>) {
+ const { _ } = useSafeLingui();
+ const onAdd = data?.onAdd;
+ const isDesignMode = useBaseCanvasMode().mode === 'design';
+ const label = _({ id: 'sequential-canvas.placeholder.add', message: 'Add step' });
+ const variant = data?.variant ?? 'row';
+
+ const handles = (
+ <>
+
+ {/* Mid-left entry so an empty-branch-lane placeholder is entered from the
+ side like a real branch child (the tail placeholder still uses Top). */}
+
+ >
+ );
+
+ if (variant === 'plus') {
+ // The box spans the row gap (no reserved row). Center the plus on the shared
+ // handle column so it sits in the gap below the last step, exactly like a
+ // between-step insert. The box itself is non-interactive so only the plus
+ // takes clicks, and it hides cleanly in readonly without moving any node.
+ return (
+
+ {handles}
+
+ {
+ e.stopPropagation();
+ if (isDesignMode) onAdd?.();
+ }}
+ />
+
+
+ );
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ if (isDesignMode) onAdd?.();
+ }}
+ >
+ {handles}
+
+ {label}
+
+ );
+}
+
+export const SequentialPlaceholderNode = memo(SequentialPlaceholderNodeComponent);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx
new file mode 100644
index 000000000..98eb3eeea
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStartNode.tsx
@@ -0,0 +1,77 @@
+import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Handle, Position } from '@uipath/apollo-react/canvas/xyflow/react';
+import { Button, cn } from '@uipath/apollo-wind';
+import { memo } from 'react';
+import { useSafeLingui } from '../../../../i18n';
+import { CanvasIcon } from '../../../utils/icon-registry';
+import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider';
+import {
+ getSeqBarVars,
+ INVISIBLE_HANDLE_STYLE,
+ SEQ_BAR_SHELL_CLASS,
+ SEQUENTIAL_BAR_HANDLE_IDS,
+} from '../../BaseNode/BaseNodeBar';
+import { BaseInnerShape } from '../../BaseNode/BaseNodeInnerShape';
+
+export interface SequentialStartNodeData extends Record {
+ /** Invoked by the "Add trigger" button. Injected view-side by the canvas. */
+ onAddTrigger?: () => void;
+}
+
+/**
+ * Synthetic first row of the sequential view. A bar-sized shell with a play icon
+ * and a right-aligned "Add trigger" call to action. It carries only a bottom
+ * source handle (nothing precedes the start). This node is injected view-only
+ * and filtered out of the change callbacks by the canvas assembly.
+ */
+function SequentialStartNodeComponent({
+ data,
+ width,
+ height,
+}: NodeProps>) {
+ const { _ } = useSafeLingui();
+ const onAddTrigger = data?.onAddTrigger;
+ const isDesignMode = useBaseCanvasMode().mode === 'design';
+
+ return (
+
+
+
+
+
+
+
+
+ {_({ id: 'sequential-canvas.start.title', message: 'Workflow start' })}
+
+
+ {isDesignMode && onAddTrigger && (
+
{
+ e.stopPropagation();
+ onAddTrigger();
+ }}
+ >
+ {_({ id: 'sequential-canvas.start.add-trigger', message: 'Add trigger' })}
+
+ )}
+
+
+
+ );
+}
+
+export const SequentialStartNode = memo(SequentialStartNodeComponent);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx
new file mode 100644
index 000000000..e3a656b0d
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider';
+import { SequentialStepNode } from './SequentialStepNode';
+
+// Keep the wrapper test lightweight and free of the registry / ReactFlow
+// context the manifest-backed bar renderer needs.
+vi.mock('../../BaseNode/BaseNodeBarNode', () => ({
+ BaseNodeBarNode: () =>
,
+}));
+
+// Minimal NodeProps stand-in for a focused wrapper test.
+// biome-ignore lint/suspicious/noExplicitAny: minimal NodeProps stub for a focused render test.
+const nodeProps = { id: 'leaf-a', data: {} } as any;
+
+describe('SequentialStepNode', () => {
+ it('renders only the registered bar node; branch insertion lives in placeholder rows', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('base-node-bar')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Add step' })).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx
new file mode 100644
index 000000000..8889f3842
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/SequentialStepNode.tsx
@@ -0,0 +1,51 @@
+import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
+import { memo } from 'react';
+import { areNodePropsEqualIgnoringPosition } from '../../../utils/nodePropsEqual';
+import type { BaseNodeData } from '../../BaseNode/BaseNode.types';
+import { BaseNodeBarNode } from '../../BaseNode/BaseNodeBarNode';
+import { useSequentialCollapsedRows } from '../SequentialCollapsedRowsContext';
+import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems';
+
+/**
+ * Sequential Canvas step row. A thin wrapper around the manifest-backed bar
+ * renderer. BaseNodeBarNode and BaseNode are sibling renderers that share
+ * presentation resolution without routing one view through the other.
+ *
+ * Register this for every real manifest node type in the sequential nodeTypes
+ * map (the node keeps its real `type`, so BaseNodeBarNode resolves the manifest).
+ *
+ * Reads the view-local collapsed row-id set from context rather than
+ * `node.data`, so a collapsed collapsible row's bar renders BaseContainer's
+ * `isStacked` treatment without mutating the sequential clone's data (D12).
+ * `React.memo`'s prop comparator only gates re-renders from prop changes;
+ * context updates always re-render consuming descendants regardless, so this
+ * stays correctly reactive to collapse toggles under
+ * `areNodePropsEqualIgnoringPosition`.
+ *
+ * Also computes the four explorer-like tree-move kebab items from
+ * `SequentialMoveActionsContext` via `useSequentialMoveMenuItems`,
+ * passed through `BaseNode.extraMenuItems` (D3). Same context-updates-always-
+ * re-render reasoning applies: a projection change (which changes which
+ * moves are available) re-renders this node even though it's memoized.
+ *
+ * Appendable branch tails are followed by the same full-width dashed
+ * SequentialPlaceholderNode used for empty branches, so this wrapper contains
+ * no separate leaf-specific add affordance.
+ */
+function SequentialStepNodeComponent(props: NodeProps>) {
+ const collapsedRowIds = useSequentialCollapsedRows();
+ const extraMenuItems = useSequentialMoveMenuItems(props.id);
+
+ return (
+
+ );
+}
+
+export const SequentialStepNode = memo(
+ SequentialStepNodeComponent,
+ areNodePropsEqualIgnoringPosition
+);
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts
new file mode 100644
index 000000000..9a9a33741
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/index.ts
@@ -0,0 +1,40 @@
+import type { NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react';
+import { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE } from '../../../constants';
+import { SequentialPlaceholderNode } from './SequentialPlaceholderNode';
+import { SequentialStartNode } from './SequentialStartNode';
+import { SequentialStepNode } from './SequentialStepNode';
+
+// Handle ids every bar exposes (re-exported for the connector/insert pipeline).
+export {
+ INVISIBLE_HANDLE_STYLE,
+ SEQUENTIAL_BAR_HANDLE_IDS,
+} from '../../BaseNode/BaseNodeBar';
+export { SequentialInsertPreviewNode } from './SequentialInsertPreviewNode';
+export {
+ SequentialPlaceholderNode,
+ type SequentialPlaceholderNodeData,
+} from './SequentialPlaceholderNode';
+export {
+ SequentialStartNode,
+ type SequentialStartNodeData,
+} from './SequentialStartNode';
+export { SequentialStepNode } from './SequentialStepNode';
+
+/**
+ * Synthetic node type keys for the start and placeholder rows. The canvas
+ * the assembly stamps injected nodes with these `type` values and merges
+ * these into the nodeTypes map; every real manifest type maps to
+ * {@link SequentialStepNode}. Defined in constants.ts so the insert pipeline
+ * (edges/sequentialInsert.ts) shares the exact same source of truth.
+ */
+export { SEQ_PLACEHOLDER_NODE_TYPE, SEQ_START_NODE_TYPE };
+
+/**
+ * Convenience nodeTypes entries for the synthetic rows. Spread these into the
+ * per-view nodeTypes map alongside the registry types mapped to
+ * {@link SequentialStepNode}.
+ */
+export const SEQUENTIAL_SYNTHETIC_NODE_TYPES: NodeTypes = {
+ [SEQ_START_NODE_TYPE]: SequentialStartNode,
+ [SEQ_PLACEHOLDER_NODE_TYPE]: SequentialPlaceholderNode,
+};
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx
new file mode 100644
index 000000000..fd8d94e9c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.test.tsx
@@ -0,0 +1,119 @@
+import { renderHook } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { BaseCanvasModeProvider } from '../../BaseCanvas/BaseCanvasModeProvider';
+import type { NodeMenuAction } from '../../NodeContextMenu';
+import {
+ type SequentialMoveActionsContextValue,
+ SequentialMoveActionsProvider,
+} from '../SequentialMoveActionsContext';
+import type { SequentialMoveOptions } from '../sequentialMoveActions';
+import { useSequentialMoveMenuItems } from './useSequentialMoveMenuItems';
+
+function makeMoveActions(
+ overrides: Partial = {}
+): SequentialMoveActionsContextValue {
+ return {
+ getMoveOptions: () => ({
+ up: undefined,
+ down: undefined,
+ indent: undefined,
+ outdent: undefined,
+ }),
+ commitMove: vi.fn(),
+ centerOnNode: vi.fn(),
+ ...overrides,
+ };
+}
+
+function wrapper(
+ mode: 'design' | 'view' | 'readonly',
+ moveActions?: SequentialMoveActionsContextValue
+) {
+ return ({ children }: { children: ReactNode }) => (
+
+ {moveActions ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+ );
+}
+
+describe('useSequentialMoveMenuItems', () => {
+ it('returns an empty array outside a SequentialMoveActionsContext provider', () => {
+ const { result } = renderHook(() => useSequentialMoveMenuItems('a'), {
+ wrapper: wrapper('design'),
+ });
+ expect(result.current).toEqual([]);
+ });
+
+ it('returns an empty array outside design mode, even with a provider', () => {
+ const moveActions = makeMoveActions();
+ const { result } = renderHook(() => useSequentialMoveMenuItems('a'), {
+ wrapper: wrapper('view', moveActions),
+ });
+ expect(result.current).toEqual([]);
+ });
+
+ it('builds four items with localized labels, in design mode with a provider', () => {
+ const moveActions = makeMoveActions();
+ const { result } = renderHook(() => useSequentialMoveMenuItems('a'), {
+ wrapper: wrapper('design', moveActions),
+ });
+
+ expect(result.current).toHaveLength(4);
+ const labels = (result.current as NodeMenuAction[]).map((item) => item.label);
+ expect(labels).toEqual(['Move up', 'Move down', 'Move into previous step', 'Move out']);
+ });
+
+ it('disables an item when its direction has no slot, and enables it otherwise', () => {
+ const options: SequentialMoveOptions = {
+ up: { id: 'up-slot', source: { nodeId: 'prev' } },
+ down: undefined,
+ indent: undefined,
+ outdent: undefined,
+ };
+ const moveActions = makeMoveActions({ getMoveOptions: () => options });
+ const { result } = renderHook(() => useSequentialMoveMenuItems('a'), {
+ wrapper: wrapper('design', moveActions),
+ });
+
+ const items = result.current as NodeMenuAction[];
+ expect(items.find((item) => item.label === 'Move up')?.disabled).toBe(false);
+ expect(items.find((item) => item.label === 'Move down')?.disabled).toBe(true);
+ });
+
+ it('clicking an enabled item calls commitMove with the node id and the resolved slot', () => {
+ const slot = { id: 'up-slot', source: { nodeId: 'prev' } };
+ const commitMove = vi.fn();
+ const moveActions = makeMoveActions({
+ getMoveOptions: () => ({ up: slot, down: undefined, indent: undefined, outdent: undefined }),
+ commitMove,
+ });
+ const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), {
+ wrapper: wrapper('design', moveActions),
+ });
+
+ const upItem = (result.current as NodeMenuAction[]).find((item) => item.label === 'Move up')!;
+ upItem.onClick();
+ expect(commitMove).toHaveBeenCalledWith('node-a', slot);
+ });
+
+ it('clicking a disabled item does not call commitMove', () => {
+ const commitMove = vi.fn();
+ const moveActions = makeMoveActions({ commitMove });
+ const { result } = renderHook(() => useSequentialMoveMenuItems('node-a'), {
+ wrapper: wrapper('design', moveActions),
+ });
+
+ const downItem = (result.current as NodeMenuAction[]).find(
+ (item) => item.label === 'Move down'
+ )!;
+ downItem.onClick();
+ expect(commitMove).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx
new file mode 100644
index 000000000..945bd4550
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/nodes/useSequentialMoveMenuItems.tsx
@@ -0,0 +1,75 @@
+import { useMemo } from 'react';
+import { useSafeLingui } from '../../../../i18n';
+import { CanvasIcon } from '../../../utils/icon-registry';
+import { useBaseCanvasMode } from '../../BaseCanvas/BaseCanvasModeProvider';
+import type { NodeMenuAction, NodeMenuItem } from '../../NodeContextMenu';
+import { useOptionalSequentialMoveActions } from '../SequentialMoveActionsContext';
+
+/**
+ * Builds the four kebab "explorer-like tree move" items (Move up, Move down,
+ * Move into previous step, Move out) for a Sequential Canvas step row, backed
+ * by the engine's binding contract
+ * (utils/sequential/mutations.ts, slotNavigation.ts). Consumed by
+ * `SequentialStepNode` and passed to `BaseNode`'s `extraMenuItems` (D3: no new
+ * `BaseNodeOverrideConfig` field -- a direct component prop instead).
+ *
+ * Disabled state comes straight from `SequentialMoveActionsContext.getMoveOptions`
+ * (see `sequentialMoveActions.ts` for the disable logic, including the
+ * bare-branch-owner gate extended to all four directions). Move actions are a
+ * TOPOLOGY mutation (D4), so -- like the ⊕ insert affordance
+ * (`SequentialConnectorEdge`'s `showInsert`) -- they only appear in design
+ * mode; outside a `SequentialMoveActionsContext` provider (isolated node
+ * stories/tests) this returns an empty array rather than throwing.
+ */
+export function useSequentialMoveMenuItems(nodeId: string): NodeMenuItem[] {
+ const { _ } = useSafeLingui();
+ const isDesignMode = useBaseCanvasMode().mode === 'design';
+ const moveActions = useOptionalSequentialMoveActions();
+
+ return useMemo(() => {
+ if (!isDesignMode || !moveActions) return [];
+ const options = moveActions.getMoveOptions(nodeId);
+
+ const item = (
+ id: string,
+ label: string,
+ icon: string,
+ slot: (typeof options)['up']
+ ): NodeMenuAction => ({
+ id,
+ label,
+ icon: ,
+ disabled: !slot,
+ onClick: () => {
+ if (slot) moveActions.commitMove(nodeId, slot);
+ },
+ });
+
+ return [
+ item(
+ 'move-up',
+ _({ id: 'sequential-canvas.move.up', message: 'Move up' }),
+ 'arrow-up',
+ options.up
+ ),
+ item(
+ 'move-down',
+ _({ id: 'sequential-canvas.move.down', message: 'Move down' }),
+ 'arrow-down',
+ options.down
+ ),
+ item(
+ 'move-indent',
+ _({ id: 'sequential-canvas.move.indent', message: 'Move into previous step' }),
+ 'indent',
+ options.indent
+ ),
+ item(
+ 'move-outdent',
+ _({ id: 'sequential-canvas.move.outdent', message: 'Move out' }),
+ 'outdent',
+ options.outdent
+ ),
+ ];
+ }, [isDesignMode, moveActions, nodeId, _]);
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts
new file mode 100644
index 000000000..1197f8cfc
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.test.ts
@@ -0,0 +1,95 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { makeWireframeFixture, WIREFRAME_NODE_IDS } from '../../utils/sequential/fixtures';
+import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert';
+import { prepareCanvasViewTransition } from './prepareCanvasViewTransition';
+
+describe('prepareCanvasViewTransition', () => {
+ it('keeps the canonical trigger for Flow while Sequential projects it into the start row', () => {
+ const fixture = makeWireframeFixture();
+
+ const flow = prepareCanvasViewTransition('flow', fixture.nodes, fixture.edges);
+ const sequential = prepareCanvasViewTransition('sequential', flow.nodes, fixture.edges);
+
+ expect(flow.nodes.find((item) => item.id === WIREFRAME_NODE_IDS.trigger)?.type).toBe(
+ 'uipath.first-run'
+ );
+ expect(
+ sequential.sequentialCompatibility?.projectedNodeIds.includes(WIREFRAME_NODE_IDS.trigger)
+ ).toBe(false);
+ expect(sequential.sequentialCompatibility?.projectedNodeIds[0]).toBe(WIREFRAME_NODE_IDS.http);
+ });
+
+ it('applies a full left-to-right layout when entering flow', () => {
+ const nodes = [node('a', 800), node('b', 0), node('c', 400)];
+ const edges = [edge('a', 'b'), edge('b', 'c')];
+
+ const result = prepareCanvasViewTransition('flow', nodes, edges, {
+ flowLayout: {
+ rankGap: 40,
+ getNodeDimensions: () => ({ width: 100, height: 60 }),
+ },
+ });
+
+ expect(result.nodes.map((item) => item.position)).toEqual([
+ { x: 0, y: 0 },
+ { x: 140, y: 0 },
+ { x: 280, y: 0 },
+ ]);
+ expect(result.flowLayout).toBeDefined();
+ });
+
+ it('clears sequential insert markers as part of the flow transition', () => {
+ const inserted: Node = {
+ ...node('inserted', 0),
+ draggable: false,
+ data: { [SEQ_INSERTED_FLAG]: true },
+ };
+
+ const result = prepareCanvasViewTransition(
+ 'flow',
+ [node('a', 0), inserted],
+ [edge('a', 'inserted')]
+ );
+
+ expect(result.nodes[1]!.data).not.toHaveProperty(SEQ_INSERTED_FLAG);
+ expect(result.nodes[1]!.draggable).toBeUndefined();
+ });
+
+ it('analyzes sequential compatibility without changing canonical nodes', () => {
+ const nodes = [node('a', 0), node('b', 100), node('c', 200)];
+ const edges = [edge('a', 'b'), edge('b', 'c'), edge('c', 'a')];
+
+ const result = prepareCanvasViewTransition('sequential', nodes, edges);
+
+ expect(result.nodes).toBe(nodes);
+ expect(result.sequentialCompatibility?.level).toBe('degraded');
+ expect(result.sequentialCompatibility?.editable).toBe(false);
+ expect(result.flowLayout).toBeUndefined();
+ });
+
+ it('preserves sticky-note geometry in both transitions by default', () => {
+ const sticky: Node = {
+ ...node('note', 900),
+ type: 'stickyNote',
+ position: { x: 900, y: 700 },
+ };
+ const nodes = [node('a', 100), node('b', 200), sticky];
+ const edges = [edge('a', 'b'), edge('note', 'a')];
+
+ const flow = prepareCanvasViewTransition('flow', nodes, edges);
+ const sequential = prepareCanvasViewTransition('sequential', flow.nodes, edges);
+
+ expect(flow.nodes.find((item) => item.id === sticky.id)?.position).toEqual({ x: 900, y: 700 });
+ expect(sequential.sequentialCompatibility?.preservedOnlyNodeIds).toEqual(['note']);
+ expect(sequential.sequentialCompatibility?.preservedOnlyEdgeIds).toContain('note-a');
+ });
+});
+
+function node(id: string, x: number): Node {
+ return { id, type: 'task', position: { x, y: 0 }, data: {} };
+}
+
+function edge(source: string, target: string): Edge {
+ return { id: `${source}-${target}`, source, target };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts
new file mode 100644
index 000000000..d13011239
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/prepareCanvasViewTransition.ts
@@ -0,0 +1,98 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import {
+ type AnalyzeSequentialCompatibilityOptions,
+ analyzeSequentialCompatibility,
+ type CanvasView,
+ type SequentialCompatibilityReport,
+} from '../../utils/sequential';
+import {
+ layoutWorkflowLeftToRight,
+ type WorkflowLayoutOptions,
+ type WorkflowLayoutResult,
+} from '../../utils/workflow-layout';
+import { synthesizePositionsForFlow } from './synthesizePositionsForFlow';
+
+export interface PrepareCanvasViewTransitionOptions {
+ flowLayout?: WorkflowLayoutOptions;
+ sequential?: AnalyzeSequentialCompatibilityOptions;
+}
+
+export interface CanvasViewTransitionResult {
+ view: CanvasView;
+ /** Canonical nodes after applying presentation state required by the target view. */
+ nodes: N[];
+ /** Present when entering flow; useful for viewport fitting and diagnostics. */
+ flowLayout?: WorkflowLayoutResult;
+ /** Present when entering sequential; topology is analyzed but never rewritten. */
+ sequentialCompatibility?: SequentialCompatibilityReport;
+}
+
+/**
+ * Prepares one canonical workflow graph for a presentation switch.
+ *
+ * Flow is a concrete left-to-right layout, so its positions and container sizes
+ * become canonical presentation state. Sequential is a derived projection: it
+ * only receives a compatibility report and leaves every node object untouched.
+ */
+export function prepareCanvasViewTransition(
+ targetView: CanvasView,
+ nodes: N[],
+ edges: Edge[],
+ options: PrepareCanvasViewTransitionOptions = {}
+): CanvasViewTransitionResult {
+ const isWorkflowNode = (node: Node) => node.type !== 'stickyNote';
+ if (targetView === 'sequential') {
+ return {
+ view: targetView,
+ nodes,
+ sequentialCompatibility: analyzeSequentialCompatibility(nodes, edges, {
+ ...options.sequential,
+ isSequenceNode: options.sequential?.isSequenceNode ?? isWorkflowNode,
+ }),
+ };
+ }
+
+ // Clear sequential-insert markers before laying out the entire flow. The
+ // synthesized coordinates are deliberately superseded by the deterministic
+ // layout, but marker cleanup still restores normal draggable behavior.
+ const normalizedNodes = synthesizePositionsForFlow(nodes, edges) as N[];
+ const flowLayout = layoutWorkflowLeftToRight(normalizedNodes, edges, {
+ ...options.flowLayout,
+ isLayoutNode: options.flowLayout?.isLayoutNode ?? isWorkflowNode,
+ });
+ let changed = normalizedNodes !== nodes;
+ const nextNodes = normalizedNodes.map((node) => {
+ const position = flowLayout.positions.get(node.id);
+ if (!position) return node;
+ const size = flowLayout.resizedNodeIds.has(node.id)
+ ? flowLayout.dimensions.get(node.id)
+ : undefined;
+ const positionChanged = node.position.x !== position.x || node.position.y !== position.y;
+ const sizeChanged =
+ !!size &&
+ (node.width !== size.width ||
+ node.height !== size.height ||
+ node.style?.width !== size.width ||
+ node.style?.height !== size.height);
+ if (!positionChanged && !sizeChanged) return node;
+
+ changed = true;
+ return {
+ ...node,
+ position,
+ ...(size
+ ? {
+ width: size.width,
+ height: size.height,
+ style: { ...node.style, width: size.width, height: size.height },
+ }
+ : {}),
+ };
+ });
+
+ return {
+ view: targetView,
+ nodes: changed ? nextNodes : nodes,
+ flowLayout,
+ };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts
new file mode 100644
index 000000000..61a913ce3
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.test.ts
@@ -0,0 +1,29 @@
+import type { EdgeProps, EdgeTypes } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { SequenceEdge } from '../Edges';
+import { resolveFlowEdgeTypes } from './resolveFlowEdgeTypes';
+
+const CustomEdge = (_props: EdgeProps) => null;
+
+describe('resolveFlowEdgeTypes', () => {
+ it('uses the same default SequenceEdge preset as the Flow canvas', () => {
+ expect(resolveFlowEdgeTypes().default).toBe(SequenceEdge);
+ expect(resolveFlowEdgeTypes().sequence).toBe(SequenceEdge);
+ });
+
+ it('retains the Flow default when adding a named host edge type', () => {
+ const result = resolveFlowEdgeTypes({ custom: CustomEdge } as EdgeTypes);
+
+ expect(result.default).toBe(SequenceEdge);
+ expect(result.sequence).toBe(SequenceEdge);
+ expect(result.custom).toBe(CustomEdge);
+ });
+
+ it('allows the host to replace the default edge renderer', () => {
+ expect(resolveFlowEdgeTypes({ default: CustomEdge }).default).toBe(CustomEdge);
+ });
+
+ it('allows the host to replace the named sequence edge renderer', () => {
+ expect(resolveFlowEdgeTypes({ sequence: CustomEdge }).sequence).toBe(CustomEdge);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts
new file mode 100644
index 000000000..165bbb8ba
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowEdgeTypes.ts
@@ -0,0 +1,12 @@
+import type { EdgeTypes } from '@uipath/apollo-react/canvas/xyflow/react';
+import { SequenceEdge } from '../Edges';
+
+const DEFAULT_FLOW_EDGE_TYPES: EdgeTypes = {
+ default: SequenceEdge,
+ sequence: SequenceEdge,
+};
+
+/** Matches the standard Flow canvas edge preset while allowing host overrides. */
+export function resolveFlowEdgeTypes(overrides?: EdgeTypes): EdgeTypes {
+ return overrides ? { ...DEFAULT_FLOW_EDGE_TYPES, ...overrides } : DEFAULT_FLOW_EDGE_TYPES;
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts
new file mode 100644
index 000000000..f8baabbe7
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { allNodeManifests } from '../../storybook-utils/manifests';
+import { BaseNode } from '../BaseNode/BaseNode';
+import { LoopCanvasNode, resolveContainerPreviewConnectionHandles } from '../LoopNode';
+import { resolveFlowNodeComponent } from './resolveFlowNodeComponent';
+
+describe('Flow loop-container experience', () => {
+ const manifest = (nodeType: string) =>
+ allNodeManifests.find((candidate) => candidate.nodeType === nodeType)!;
+
+ it.each([
+ 'uipath.control-flow.foreach',
+ 'uipath.control-flow.while',
+ ])('renders %s with LoopCanvasNode', (nodeType) => {
+ expect(manifest(nodeType).display.shape).toBe('container');
+ expect(resolveFlowNodeComponent(manifest(nodeType))).toBe(LoopCanvasNode);
+ });
+
+ it('keeps While body and loop-back handles on the inner container boundary', () => {
+ const whileManifest = manifest('uipath.control-flow.while');
+ const innerHandles = whileManifest.handleConfiguration
+ .filter((group) => group.boundary === 'inner')
+ .flatMap((group) => group.handles.map((handle) => handle.id));
+
+ expect(innerHandles).toEqual(['body', 'loopBack']);
+ });
+
+ it.each([
+ ['uipath.control-flow.foreach', 'start', 'continue'],
+ ['uipath.control-flow.while', 'body', 'loopBack'],
+ ])('supports first-child preview wiring for %s', (nodeType, sourceHandleId, targetHandleId) => {
+ expect(
+ resolveContainerPreviewConnectionHandles(manifest(nodeType), { nodeId: 'loop' })
+ ).toEqual(expect.objectContaining({ sourceHandleId, targetHandleId }));
+ });
+
+ it('continues to render regular workflow steps with BaseNode', () => {
+ expect(resolveFlowNodeComponent(manifest('uipath.script'))).toBe(BaseNode);
+ });
+
+ it('gives the canonical first-run circle an output anchor for its Flow edge', () => {
+ const firstRun = manifest('uipath.first-run');
+ const output = firstRun.handleConfiguration
+ .flatMap((group) => group.handles)
+ .find((handle) => handle.id === 'output');
+
+ expect(firstRun.display.shape).toBe('circle');
+ expect(output).toMatchObject({ type: 'source', handleType: 'output' });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts
new file mode 100644
index 000000000..84904e739
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/resolveFlowNodeComponent.ts
@@ -0,0 +1,12 @@
+import type { NodeTypes } from '@uipath/apollo-react/canvas/xyflow/react';
+import type { NodeManifest } from '../../schema';
+import { isContainerNodeManifest } from '../../utils';
+import { BaseNode } from '../BaseNode/BaseNode';
+import { LoopCanvasNode } from '../LoopNode';
+
+/** Resolves the standard Flow renderer from the manifest's semantic shape. */
+export function resolveFlowNodeComponent(
+ manifest: Pick | undefined
+): NodeTypes[string] {
+ return isContainerNodeManifest(manifest) ? LoopCanvasNode : BaseNode;
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts
new file mode 100644
index 000000000..181f9940e
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.test.ts
@@ -0,0 +1,262 @@
+import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { PREVIEW_EDGE_ID, PREVIEW_NODE_ID } from '../../constants';
+import type { GraphChangeSet } from '../../utils/sequential/sequential.types';
+import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert';
+import {
+ forwardSequentialEdgeChanges,
+ forwardSequentialNodeChanges,
+ graphChangeSetToEdgeChanges,
+ graphChangeSetToNodeChanges,
+} from './sequentialChangeFilters';
+import { SEQ_CONNECTOR_EDGE_TYPE, SEQ_START_ROW_ID } from './sequentialGraph.constants';
+
+const SYNTHETIC = new Set([SEQ_START_ROW_ID]);
+
+function canonical(id: string, position = { x: 100, y: 100 }): Node {
+ return { id, type: 'uipath.script', position, width: 288, data: { display: { label: id } } };
+}
+
+describe('forwardSequentialNodeChanges', () => {
+ const canonicalById = new Map([['a', canonical('a')]]);
+
+ it('drops position and dimension changes (the derivation owns geometry)', () => {
+ const changes: NodeChange[] = [
+ { type: 'position', id: 'a', position: { x: 9, y: 9 } },
+ { type: 'dimensions', id: 'a', dimensions: { width: 896, height: 72 } },
+ ];
+ expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]);
+ });
+
+ it('drops changes referencing synthetic rows and the preview node', () => {
+ const changes: NodeChange[] = [
+ { type: 'select', id: SEQ_START_ROW_ID, selected: true },
+ { type: 'select', id: PREVIEW_NODE_ID, selected: true },
+ { type: 'remove', id: PREVIEW_NODE_ID },
+ ];
+ expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual([]);
+ });
+
+ it('drops changes referencing synthetic empty-lane placeholder nodes', () => {
+ const synthetic = new Set([
+ ...SYNTHETIC,
+ '__sequential-lane__if::true',
+ '__sequential-lane__loop::start',
+ ]);
+ const changes: NodeChange[] = [
+ { type: 'select', id: '__sequential-lane__if::true', selected: true },
+ { type: 'remove', id: '__sequential-lane__loop::start' },
+ ];
+ expect(forwardSequentialNodeChanges(changes, synthetic, canonicalById)).toEqual([]);
+ });
+
+ it('does not drop a canonical node merely because its id resembles a synthetic id', () => {
+ const id = '__sequential-lane__consumer-owned';
+ const changes: NodeChange[] = [{ type: 'select', id, selected: true }];
+
+ expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes);
+ });
+
+ it('passes select and remove of real nodes through', () => {
+ const changes: NodeChange[] = [
+ { type: 'select', id: 'a', selected: true },
+ { type: 'remove', id: 'a' },
+ ];
+ expect(forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById)).toEqual(changes);
+ });
+
+ it('rewrites a rename replace to merge data onto canonical, preserving geometry', () => {
+ const cloneItem: Node = {
+ id: 'a',
+ type: 'uipath.script',
+ position: { x: 0, y: 5000 }, // clone/view position
+ width: 896, // bar width
+ selected: true,
+ data: { display: { label: 'renamed' } },
+ };
+ const changes: NodeChange[] = [{ type: 'replace', id: 'a', item: cloneItem }];
+
+ const [out] = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById);
+ expect(out?.type).toBe('replace');
+ const item = (out as { item: Node }).item;
+ // Canonical geometry preserved, only data + selection merged.
+ expect(item.position).toEqual({ x: 100, y: 100 });
+ expect(item.width).toBe(288);
+ expect(item.selected).toBe(true);
+ expect((item.data as { display: { label: string } }).display.label).toBe('renamed');
+ });
+
+ it('forwards an inserted node add, drops non-inserted adds', () => {
+ const insertedItem: Node = {
+ id: 'new',
+ type: 'uipath.slack',
+ position: { x: 0, y: 0 },
+ data: { [SEQ_INSERTED_FLAG]: true },
+ };
+ const changes: NodeChange[] = [
+ { type: 'add', item: insertedItem },
+ { type: 'add', item: canonical('other') },
+ ];
+ const out = forwardSequentialNodeChanges(changes, SYNTHETIC, canonicalById);
+ expect(out).toHaveLength(1);
+ expect((out[0] as { item: Node }).item.id).toBe('new');
+ });
+});
+
+describe('forwardSequentialEdgeChanges', () => {
+ const canonicalEdges: Edge[] = [
+ { id: 'e-st', source: 's', target: 't' },
+ { id: 'e-other', source: 'x', target: 'y' },
+ ];
+ const canonicalEdgeIds = new Set(canonicalEdges.map((edge) => edge.id));
+
+ it('drops connector and preview edge adds', () => {
+ const changes: EdgeChange[] = [
+ {
+ type: 'add',
+ item: { id: 'conn:1', source: 's', target: 't', type: SEQ_CONNECTOR_EDGE_TYPE },
+ },
+ { type: 'add', item: { id: PREVIEW_EDGE_ID, source: 's', target: PREVIEW_NODE_ID } },
+ ];
+ expect(forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges)).toEqual([]);
+ });
+
+ it('forwards healed default edges and removes the shadowed canonical edge on insert', () => {
+ const changes: EdgeChange[] = [
+ { type: 'add', item: { id: 'edge_s--new-', source: 's', target: 'new', type: 'default' } },
+ { type: 'add', item: { id: 'edge_new--t-', source: 'new', target: 't', type: 'default' } },
+ ];
+ const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges);
+
+ // Both healed edges forwarded.
+ expect(out.filter((change) => change.type === 'add')).toHaveLength(2);
+ // The canonical s->t edge is now shadowed and removed.
+ expect(out).toContainEqual({ type: 'remove', id: 'e-st' });
+ // The unrelated canonical edge is untouched.
+ expect(out).not.toContainEqual({ type: 'remove', id: 'e-other' });
+ });
+
+ it('uses the exact split marker and preserves parallel canonical edges', () => {
+ const parallelEdges: Edge[] = [
+ { id: 'e-split', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' },
+ { id: 'e-parallel', source: 's', sourceHandle: 'out', target: 't', targetHandle: 'in' },
+ ];
+ const ids = new Set(parallelEdges.map((edge) => edge.id));
+ const changes: EdgeChange[] = [
+ {
+ type: 'add',
+ item: {
+ id: 'e-s-new',
+ source: 's',
+ sourceHandle: 'out',
+ target: 'new',
+ data: { __sequentialSplitEdgeId: 'e-split', keep: true },
+ },
+ },
+ {
+ type: 'add',
+ item: {
+ id: 'e-new-t',
+ source: 'new',
+ target: 't',
+ targetHandle: 'in',
+ data: { __sequentialSplitEdgeId: 'e-split' },
+ },
+ },
+ ];
+
+ const out = forwardSequentialEdgeChanges(changes, ids, parallelEdges);
+ expect(out).toContainEqual({ type: 'remove', id: 'e-split' });
+ expect(out).not.toContainEqual({ type: 'remove', id: 'e-parallel' });
+ const added = out.filter((change) => change.type === 'add');
+ expect(added[0]?.item.data).toEqual({ keep: true });
+ expect(added[1]?.item.data).toEqual({});
+ });
+
+ it('forwards removes/selects of real canonical edges, drops others', () => {
+ const changes: EdgeChange[] = [
+ { type: 'remove', id: 'e-st' },
+ { type: 'remove', id: 'conn:1' },
+ { type: 'select', id: 'e-other', selected: true },
+ { type: 'select', id: 'conn:2', selected: true },
+ ];
+ const out = forwardSequentialEdgeChanges(changes, canonicalEdgeIds, canonicalEdges);
+ expect(out).toEqual([
+ { type: 'remove', id: 'e-st' },
+ { type: 'select', id: 'e-other', selected: true },
+ ]);
+ });
+});
+
+describe('graphChangeSetToNodeChanges (move operations)', () => {
+ it('produces a plain remove for an id that is only in removeNodeIds', () => {
+ const changeSet: GraphChangeSet = {
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: ['a'],
+ removeEdgeIds: [],
+ };
+ expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'remove', id: 'a' }]);
+ });
+
+ it('produces a plain add for a genuinely new node (not also in removeNodeIds)', () => {
+ const node = canonical('new');
+ const changeSet: GraphChangeSet = {
+ addNodes: [node],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ };
+ expect(graphChangeSetToNodeChanges(changeSet)).toEqual([{ type: 'add', item: node }]);
+ });
+
+ it('rewrites a paired remove+add (same id, e.g. a cross-container parentId rewrite) into a single replace, preserving parentId', () => {
+ const movedNode: Node = { ...canonical('a'), parentId: 'new-container' };
+ const changeSet: GraphChangeSet = {
+ addNodes: [movedNode],
+ addEdges: [],
+ removeNodeIds: ['a'],
+ removeEdgeIds: [],
+ };
+ const changes = graphChangeSetToNodeChanges(changeSet);
+ expect(changes).toEqual([{ type: 'replace', id: 'a', item: movedNode }]);
+ // The parentId rewrite -- the whole point of the move -- survives.
+ expect((changes[0] as { item: Node }).item.parentId).toBe('new-container');
+ });
+
+ it('produces no node changes for a same-container move (only edges change)', () => {
+ const changeSet: GraphChangeSet = {
+ addNodes: [],
+ addEdges: [{ id: 'e1', source: 'a', target: 'b' }],
+ removeNodeIds: [],
+ removeEdgeIds: ['e0'],
+ };
+ expect(graphChangeSetToNodeChanges(changeSet)).toEqual([]);
+ });
+});
+
+describe('graphChangeSetToEdgeChanges (move operations)', () => {
+ it('translates removeEdgeIds and addEdges into remove/add EdgeChanges', () => {
+ const edge: Edge = { id: 'e1', source: 'a', target: 'b' };
+ const changeSet: GraphChangeSet = {
+ addNodes: [],
+ addEdges: [edge],
+ removeNodeIds: [],
+ removeEdgeIds: ['e0'],
+ };
+ expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([
+ { type: 'remove', id: 'e0' },
+ { type: 'add', item: edge },
+ ]);
+ });
+
+ it('returns an empty array for a no-op GraphChangeSet', () => {
+ const changeSet: GraphChangeSet = {
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ };
+ expect(graphChangeSetToEdgeChanges(changeSet)).toEqual([]);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts
new file mode 100644
index 000000000..a1837e242
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialChangeFilters.ts
@@ -0,0 +1,246 @@
+import type { Edge, EdgeChange, Node, NodeChange } from '@uipath/apollo-react/canvas/xyflow/react';
+import { PREVIEW_NODE_ID } from '../../constants';
+import { isPreviewEdge } from '../../utils/createPreviewNode';
+import type { GraphChangeSet } from '../../utils/sequential/sequential.types';
+import { SEQ_INSERTED_FLAG, SEQ_SPLIT_EDGE_ID_KEY } from './edges/sequentialInsert';
+import { SEQ_CONNECTOR_EDGE_TYPE } from './sequentialGraph.constants';
+
+/**
+ * Change filtering is the robust guarantee that the sequential view never
+ * corrupts canonical state (seam 2). In the sequential view the nodes/edges
+ * handed to ReactFlow are DERIVED clones + connector edges, not the canonical
+ * graph. ReactFlow (in controlled mode) reports every store mutation through
+ * onNodesChange / onEdgesChange as diff changes — including position and
+ * dimension changes the derivation owns, and the Add Node pipeline's collision
+ * pass nudging clones. Forwarding those verbatim would overwrite the consumer's
+ * canonical positions/geometry with view-only values.
+ *
+ * These pure functions translate the derived-view change stream into the subset
+ * that is meaningful for canonical state:
+ * - position + dimension changes are DROPPED (the derivation owns geometry);
+ * - changes referencing synthetic rows (start bar, placeholder, preview) are
+ * DROPPED (they never exist in canonical state);
+ * - a `replace` on a real node (e.g. an inline rename via updateNodeData) is
+ * rewritten to merge only `data` + `selected` onto the CANONICAL node, so the
+ * clone's view geometry (type / position / width / draggable) can't leak;
+ * - `select` and `remove` on real nodes/edges pass through;
+ * - an insert's added node (stamped `seqInserted`) and its healed real edges
+ * pass through, and the canonical edge the insert split is removed.
+ */
+
+function nodeChangeId(change: NodeChange): string | undefined {
+ return change.type === 'add' ? change.item.id : change.id;
+}
+
+function isInsertedNode(node: N): boolean {
+ return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true;
+}
+
+/** Filters/translates node changes from the derived view for the consumer's canonical state. */
+export function forwardSequentialNodeChanges(
+ changes: NodeChange[],
+ syntheticIds: ReadonlySet,
+ canonicalById: ReadonlyMap
+): NodeChange[] {
+ const out: NodeChange[] = [];
+
+ for (const change of changes) {
+ const id = nodeChangeId(change);
+ if (id !== undefined && (id === PREVIEW_NODE_ID || syntheticIds.has(id))) {
+ continue;
+ }
+
+ switch (change.type) {
+ case 'add':
+ // Only a genuinely inserted node (seqInserted) reaches canonical here;
+ // any other add is a derivation/preview artifact.
+ if (isInsertedNode(change.item)) out.push(change);
+ break;
+ case 'remove':
+ case 'select':
+ out.push(change);
+ break;
+ case 'replace': {
+ const canonical = canonicalById.get(change.id);
+ if (!canonical) break;
+ out.push({
+ type: 'replace',
+ id: change.id,
+ item: { ...canonical, data: change.item.data, selected: change.item.selected },
+ });
+ break;
+ }
+ // Derivation owns positions and bar dimensions; never forward them.
+ default:
+ break;
+ }
+ }
+
+ return out;
+}
+
+function isForwardableEdgeAdd(edge: E): boolean {
+ return !isPreviewEdge(edge) && edge.type !== SEQ_CONNECTOR_EDGE_TYPE;
+}
+
+function splitEdgeId(edge: Edge): string | undefined {
+ const value = (edge.data as Record | undefined)?.[SEQ_SPLIT_EDGE_ID_KEY];
+ return typeof value === 'string' ? value : undefined;
+}
+
+function withoutSplitMarker(edge: E): E {
+ if (!splitEdgeId(edge)) return edge;
+ const { [SEQ_SPLIT_EDGE_ID_KEY]: _marker, ...data } = edge.data as Record;
+ return { ...edge, data };
+}
+
+/**
+ * Canonical edges shadowed by an insert: when a new node is spliced between S and
+ * T, the pipeline adds S->new and new->T, so the pivot node is the id that is
+ * both a target and a source among the added edges. Any pre-existing canonical
+ * edge S->T is now redundant and must be removed.
+ */
+function findShadowedCanonicalEdgeIds(
+ addedEdges: E[],
+ canonicalEdges: readonly E[]
+): string[] {
+ const targetsOf = new Map>();
+ const sourcesOf = new Map>();
+ for (const edge of addedEdges) {
+ const incoming = targetsOf.get(edge.target) ?? [];
+ incoming.push({ nodeId: edge.source, handleId: edge.sourceHandle });
+ targetsOf.set(edge.target, incoming);
+ const outgoing = sourcesOf.get(edge.source) ?? [];
+ outgoing.push({ nodeId: edge.target, handleId: edge.targetHandle });
+ sourcesOf.set(edge.source, outgoing);
+ }
+
+ const shadowed = new Set();
+ for (const [pivot, incomingSources] of targetsOf) {
+ const outgoingTargets = sourcesOf.get(pivot);
+ if (!outgoingTargets) continue;
+ for (const source of incomingSources) {
+ for (const target of outgoingTargets) {
+ for (const edge of canonicalEdges) {
+ if (
+ edge.source === source.nodeId &&
+ edge.target === target.nodeId &&
+ (edge.sourceHandle ?? undefined) === (source.handleId ?? undefined) &&
+ (edge.targetHandle ?? undefined) === (target.handleId ?? undefined)
+ ) {
+ shadowed.add(edge.id);
+ }
+ }
+ }
+ }
+ }
+ return [...shadowed];
+}
+
+/** Filters/translates edge changes from the derived view for the consumer's canonical state. */
+export function forwardSequentialEdgeChanges(
+ changes: EdgeChange[],
+ canonicalEdgeIds: ReadonlySet,
+ canonicalEdges: readonly E[]
+): EdgeChange[] {
+ const out: EdgeChange[] = [];
+ const addedEdges: E[] = [];
+ const exactSplitEdgeIds = new Set();
+
+ for (const change of changes) {
+ switch (change.type) {
+ case 'add':
+ if (isForwardableEdgeAdd(change.item)) {
+ const splitId = splitEdgeId(change.item);
+ if (splitId) exactSplitEdgeIds.add(splitId);
+ const item = withoutSplitMarker(change.item);
+ out.push({ ...change, item });
+ addedEdges.push(item);
+ }
+ break;
+ case 'remove':
+ case 'select':
+ if (canonicalEdgeIds.has(change.id)) out.push(change);
+ break;
+ // Connector `data` replaces are view-only; canonical edges are not replaced
+ // through the view.
+ default:
+ break;
+ }
+ }
+
+ if (addedEdges.length > 0) {
+ const removedIds = new Set(
+ out.filter((change) => change.type === 'remove').map((change) => change.id)
+ );
+ // A pending insertion carries the exact canonical edge id. Do not also run
+ // topology inference in that case: two parallel edges can legitimately
+ // have identical endpoints and handles, and inference would remove both.
+ const shadowedIds =
+ exactSplitEdgeIds.size > 0
+ ? exactSplitEdgeIds
+ : new Set(findShadowedCanonicalEdgeIds(addedEdges, canonicalEdges));
+ for (const id of shadowedIds) {
+ if (!removedIds.has(id)) out.push({ type: 'remove', id });
+ }
+ }
+
+ return out;
+}
+
+/**
+ * Converts a {@link GraphChangeSet} (the tree move operations -- see
+ * `sequentialMoveActions.ts` / `SequentialMoveActionsContext.tsx`) into
+ * `NodeChange[]` for the CANONICAL `onNodesChange` callback (D10: the public
+ * API stays the standard change stream).
+ *
+ * Unlike `forwardSequentialNodeChanges` above, these two functions do NOT
+ * translate the derived VIEW's own xyflow-emitted changes (that seam exists
+ * to strip view-only geometry off a DERIVED clone). A move's `GraphChangeSet`
+ * is built directly from `moveSubtree` over the CANONICAL `{nodes, edges}`
+ * (see `mutations.ts`), so every node/edge here already has the real shape --
+ * routing it through the clone-stripping translator would be wrong (that
+ * translator's own `replace` case deliberately drops `parentId`, which is
+ * exactly the field a cross-container move needs to carry). `moveSubtree`
+ * expresses "this node's parentId changed" as a paired remove+add (same id in
+ * both `removeNodeIds` and `addNodes`); that pairing is rewritten here as a
+ * `replace` so xyflow updates the existing node in place instead of a
+ * remove/re-add flicker. A genuinely new id in `addNodes` (never happens for
+ * a move today, since moves never create nodes) still round-trips correctly
+ * as a plain `add`.
+ */
+export function graphChangeSetToNodeChanges(
+ changeSet: GraphChangeSet
+): NodeChange[] {
+ const removedIds = new Set(changeSet.removeNodeIds);
+ const changes: NodeChange[] = [];
+
+ for (const node of changeSet.addNodes) {
+ if (removedIds.has(node.id)) {
+ changes.push({ type: 'replace', id: node.id, item: node as N });
+ } else {
+ changes.push({ type: 'add', item: node as N });
+ }
+ }
+
+ const replacedIds = new Set(changeSet.addNodes.map((node) => node.id));
+ for (const id of changeSet.removeNodeIds) {
+ if (!replacedIds.has(id)) changes.push({ type: 'remove', id });
+ }
+
+ return changes;
+}
+
+/** Edge half of {@link graphChangeSetToNodeChanges}: a plain add/remove translation. */
+export function graphChangeSetToEdgeChanges(
+ changeSet: GraphChangeSet
+): EdgeChange[] {
+ const changes: EdgeChange[] = changeSet.removeEdgeIds.map((id) => ({
+ type: 'remove' as const,
+ id,
+ }));
+ for (const edge of changeSet.addEdges) {
+ changes.push({ type: 'add', item: edge as E });
+ }
+ return changes;
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts
new file mode 100644
index 000000000..e678a698f
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialGraph.constants.ts
@@ -0,0 +1,34 @@
+/**
+ * Shared, dependency-free constants for the sequential graph derivation
+ * (useSequentialGraph), the connector edges, and the change filters. Kept in its
+ * own module so the pure change-filter helpers don't pull in the React hook.
+ */
+
+/** Edge `type` the derivation stamps on every connector edge (the registered connector edge type). */
+export const SEQ_CONNECTOR_EDGE_TYPE = 'sequentialConnector';
+
+/**
+ * Stable node ids for the two synthetic rows the derivation injects view-only.
+ * They are filtered out of onNodesChange / onEdgesChange before forwarding, and
+ * never appear in canonical state. The double-underscore fencing keeps them from
+ * colliding with any real node id.
+ */
+export const SEQ_START_ROW_ID = '__sequential-start__';
+export const SEQ_PLACEHOLDER_ROW_ID = '__sequential-placeholder__';
+
+/** Edge ids for the synthetic connectors joining the start/placeholder bars. */
+export const SEQ_START_EDGE_ID = '__sequential-start-edge__';
+export const SEQ_PLACEHOLDER_EDGE_ID = '__sequential-placeholder-edge__';
+
+/**
+ * Node-count ceiling under which the sequential view renders EVERY row into the
+ * DOM (onlyRenderVisibleElements={false}) so reading order equals row order for
+ * screen readers (D8). Above it, xyflow's viewport virtualization is re-enabled
+ * (matching the flow canvas default): rendering many hundred fixed bars at once
+ * makes xyflow fire updateNodeInternals for all of them on mount, and that burst
+ * drives xyflow's own ResizeObserver / store-rerender cycle past React's nested
+ * update limit. Real sequential flows sit far below this; a run this large is a
+ * navigable list only in the loosest sense, so trading full-DOM a11y for
+ * virtualization stability at that scale is the right call. Documented tradeoff.
+ */
+export const SEQ_FULL_RENDER_MAX_NODES = 150;
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts
new file mode 100644
index 000000000..87802670f
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.test.ts
@@ -0,0 +1,261 @@
+import { describe, expect, it } from 'vitest';
+import {
+ CONTAINER_CHAIN_NODE_IDS,
+ CROSS_CONTAINER_BRANCH_NODE_IDS,
+ makeContainerChainFixture,
+ makeCrossContainerBranchFixture,
+ makeDiamondFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from '../../utils/sequential/fixtures';
+import { projectSequence } from '../../utils/sequential/projectSequence';
+import { findMoveUpSlot } from '../../utils/sequential/slotNavigation';
+import {
+ closesLoopToOwner,
+ computeSequentialMoveOptions,
+ getSequentialMoveSlot,
+ isBareBranchOwner,
+ resolveSlotForCommit,
+ resolveTailInsertionSlot,
+} from './sequentialMoveActions';
+
+// Every fixture here uses `uipath.control-flow.foreach` for containers and
+// `uipath.control-flow.decision` for branch owners (If/Switch); this stand-in
+// registry check matches that convention rather than a real manifest lookup,
+// since this module only needs a `nodeId => boolean` predicate.
+const isForEachContainer = (nodeId: string, nodes: { id: string; type?: string }[]) =>
+ nodes.find((n) => n.id === nodeId)?.type === 'uipath.control-flow.foreach';
+
+describe('isBareBranchOwner', () => {
+ it('is true for a Decision (branch owner, not a container)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ expect(
+ isBareBranchOwner(projection, WIREFRAME_NODE_IDS.ifNode, (id) =>
+ isForEachContainer(id, nodes)
+ )
+ ).toBe(true);
+ });
+
+ it('is false for a For Each (container)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ expect(
+ isBareBranchOwner(projection, WIREFRAME_NODE_IDS.forEach, (id) =>
+ isForEachContainer(id, nodes)
+ )
+ ).toBe(false);
+ });
+
+ it('is false for a plain leaf step', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ expect(
+ isBareBranchOwner(projection, WIREFRAME_NODE_IDS.javascript, (id) =>
+ isForEachContainer(id, nodes)
+ )
+ ).toBe(false);
+ });
+});
+
+describe('computeSequentialMoveOptions', () => {
+ it('disables ALL FOUR directions for a bare branch owner (If/Switch) that is the sole/first child of its container (wireframe)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const isContainerNode = (id: string) => isForEachContainer(id, nodes);
+
+ const options = computeSequentialMoveOptions(
+ projection,
+ WIREFRAME_NODE_IDS.ifNode,
+ isContainerNode
+ );
+ expect(options).toEqual({
+ up: undefined,
+ down: undefined,
+ indent: undefined,
+ outdent: undefined,
+ });
+ });
+
+ it('disables ALL FOUR directions for a top-level bare branch owner, even though findMoveUpSlot ALONE would return a defined (unsound) slot', () => {
+ // makeDiamondFixture: A -> If {true: B, false: C} -> D. A->If is a genuine
+ // 'step' connector (A has a single outgoing edge), so `findMoveUpSlot`
+ // does NOT naturally return undefined for `If` here the way it does for
+ // the wireframe's `ifNode` (which is the FIRST/only child of its
+ // container, reached only via a 'branch-entry' connector) -- this is
+ // exactly the premise that makes this module's extra gate load-bearing
+ // for Move Up (see isBareBranchOwner's doc comment).
+ const { nodes, edges } = makeDiamondFixture();
+ const projection = projectSequence(nodes, edges);
+ const isContainerNode = (id: string) => isForEachContainer(id, nodes);
+
+ expect(findMoveUpSlot(projection, 'if')).toBeDefined();
+
+ const options = computeSequentialMoveOptions(projection, 'if', isContainerNode);
+ expect(options).toEqual({
+ up: undefined,
+ down: undefined,
+ indent: undefined,
+ outdent: undefined,
+ });
+ });
+
+ it('does not gate a container (For Each): outdent works normally for its body children', () => {
+ const { nodes, edges } = makeContainerChainFixture();
+ const projection = projectSequence(nodes, edges);
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const isContainerNode = (id: string) => isForEachContainer(id, nodes);
+
+ const options = computeSequentialMoveOptions(projection, ids.y, isContainerNode);
+ expect(options.outdent).toBeDefined();
+ });
+
+ it('allows move up/down for a plain leaf step (javascript in the wireframe)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const isContainerNode = (id: string) => isForEachContainer(id, nodes);
+
+ const options = computeSequentialMoveOptions(
+ projection,
+ WIREFRAME_NODE_IDS.javascript,
+ isContainerNode
+ );
+ expect(options.up).toBeDefined();
+ expect(options.down).toBeDefined();
+ });
+
+ it('does not synthesize an outdent seam for a branch-lane child', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const options = computeSequentialMoveOptions(projection, WIREFRAME_NODE_IDS.thenJs, (id) =>
+ isForEachContainer(id, nodes)
+ );
+ expect(options.outdent).toBeUndefined();
+ });
+
+ it('disables ALL FOUR directions for a bare branch owner nested across a container boundary (cross-container fixture)', () => {
+ const { nodes, edges } = makeCrossContainerBranchFixture();
+ const projection = projectSequence(nodes, edges);
+ const ids = CROSS_CONTAINER_BRANCH_NODE_IDS;
+ const isContainerNode = (id: string) => isForEachContainer(id, nodes);
+
+ const options = computeSequentialMoveOptions(projection, ids.ifNode, isContainerNode);
+ expect(options).toEqual({
+ up: undefined,
+ down: undefined,
+ indent: undefined,
+ outdent: undefined,
+ });
+ });
+});
+
+describe('closesLoopToOwner', () => {
+ it('identifies the raw continue edge from a body tail back to its owner', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ expect(closesLoopToOwner(projection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(false);
+ // The projected owner of Then is the If branch, not the For Each container;
+ // branch protection above handles this shape. Verify the helper on a small
+ // direct loop body where the row owner and close-edge target are identical.
+ const directProjection = {
+ ...projection,
+ rows: projection.rows.map((row) =>
+ row.nodeId === WIREFRAME_NODE_IDS.thenJs
+ ? { ...row, parentRowId: WIREFRAME_NODE_IDS.forEach }
+ : row
+ ),
+ };
+ expect(closesLoopToOwner(directProjection, WIREFRAME_NODE_IDS.thenJs, edges)).toBe(true);
+ });
+});
+
+describe('getSequentialMoveSlot', () => {
+ it('reads the matching direction from a SequentialMoveOptions bag', () => {
+ const options = {
+ up: { id: 'up' },
+ down: undefined,
+ indent: { id: 'indent' },
+ outdent: undefined,
+ } as const;
+ expect(getSequentialMoveSlot(options, 'up')).toEqual({ id: 'up' });
+ expect(getSequentialMoveSlot(options, 'down')).toBeUndefined();
+ expect(getSequentialMoveSlot(options, 'indent')).toEqual({ id: 'indent' });
+ expect(getSequentialMoveSlot(options, 'outdent')).toBeUndefined();
+ });
+});
+
+describe('resolveSlotForCommit', () => {
+ const nodesById = new Map([
+ ['n1', { id: 'n1', type: 'uipath.script', position: { x: 0, y: 0 } }],
+ ]);
+
+ it('replaces a synthesized DEFAULT_SOURCE_HANDLE_ID with the registry-resolved default source handle', () => {
+ const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } };
+ const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle');
+ expect(resolved.source?.handleId).toBe('real-output-handle');
+ });
+
+ it('leaves the slot unchanged when the source handle is not the synthesized default', () => {
+ const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'custom-handle' } };
+ const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle');
+ expect(resolved).toBe(slot);
+ });
+
+ it('leaves the slot unchanged when the registry has nothing better to offer', () => {
+ const slot = { id: 'slot', source: { nodeId: 'n1', handleId: 'output' } };
+ const resolved = resolveSlotForCommit(slot, nodesById, () => undefined);
+ expect(resolved).toBe(slot);
+ });
+
+ it('leaves a target-only slot (no source) unchanged', () => {
+ const slot = { id: 'slot', target: { nodeId: 'n1' } };
+ const resolved = resolveSlotForCommit(slot, nodesById, () => 'real-output-handle');
+ expect(resolved).toBe(slot);
+ });
+});
+
+describe('resolveTailInsertionSlot', () => {
+ it('uses the terminal manifest default source handle and skips trailing orphans', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ projection.rows.push({
+ nodeId: 'orphan',
+ depth: 0,
+ collapsible: false,
+ collapsed: false,
+ visible: true,
+ orphan: true,
+ });
+ const slot = resolveTailInsertionSlot(
+ projection,
+ [...nodes, { id: 'orphan', type: 'orphan', position: { x: 0, y: 0 }, data: {} }],
+ (type) => (type === 'uipath.send-message' ? 'success-port' : undefined)
+ );
+ expect(slot).toEqual({
+ id: `slot:tail:${WIREFRAME_NODE_IDS.sendMessage}`,
+ source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'success-port' },
+ });
+ });
+
+ it('uses the lone start node when the projected sequence is empty', () => {
+ const start = {
+ id: 'start',
+ type: 'uipath.trigger.manual',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const projection = projectSequence([start], [], { isStartNode: () => true });
+
+ const slot = resolveTailInsertionSlot(
+ projection,
+ [start],
+ () => 'trigger-output',
+ (node) => node.id === start.id
+ );
+
+ expect(slot).toEqual({
+ id: 'slot:tail:start',
+ source: { nodeId: 'start', handleId: 'trigger-output' },
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts
new file mode 100644
index 000000000..03cb4211d
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/sequentialMoveActions.ts
@@ -0,0 +1,178 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_SOURCE_HANDLE_ID } from '../../constants';
+import type { InsertionSlot, SequenceProjection } from '../../utils/sequential/sequential.types';
+import {
+ findIndentSlot,
+ findMoveDownSlot,
+ findMoveUpSlot,
+ findOutdentSlot,
+} from '../../utils/sequential/slotNavigation';
+
+/**
+ * Pure core for the explorer-like tree move operations (Move up/down,
+ * indent/outdent), kept dependency-free from React so the disable logic and
+ * handle-resolution are unit-testable without mounting BaseNode/BaseCanvas.
+ * The React binding (SequentialMoveActionsContext) wires this to the
+ * projection/canonical graph/registry and to onNodesChange/onEdgesChange.
+ */
+
+export type SequentialMoveDirection = 'up' | 'down' | 'indent' | 'outdent';
+
+/** A candidate move: `slot` is the target `moveSubtree` would use; `undefined` means disabled. */
+export interface SequentialMoveOptions {
+ up: InsertionSlot | undefined;
+ down: InsertionSlot | undefined;
+ indent: InsertionSlot | undefined;
+ outdent: InsertionSlot | undefined;
+}
+
+/**
+ * True when `nodeId`'s row is a "bare branch owner": collapsible (per
+ * `SequenceRow.collapsible`, which is true for BOTH containers and branch
+ * owners like If/Switch) but NOT a structural container.
+ *
+ * ENGINE CONTRACT EXTENSION: the binding
+ * contract text calls for this gate on Outdent only. This module applies it
+ * to ALL FOUR operations instead, because the underlying limitation is not
+ * outdent-specific. `moveSubtree`'s own doc comment (utils/sequential/mutations.ts)
+ * and its `collectOwnSeam` helper establish that a bare branch owner has NO
+ * genuine "own outgoing" connector -- `projectSequence` only ever emits
+ * `branch-entry` connectors sourced at such a node (into its Then/Else lanes),
+ * never a real forward `step` past it, because the flow only "continues"
+ * again via the merge node reached through the branch tails.
+ *
+ * Consequence, verified against the engine's own
+ * `makeCrossContainerBranchFixture` test (mutations.test.ts, "relocates a bare
+ * branch owner WITH its lane content across a container boundary"): the ONLY
+ * slot shape that test exercises for moving such a node is a SOURCE-ONLY
+ * (append) slot with no `target`/`graphEdgeId` -- explicitly "so the splice
+ * only ADDS an incoming edge to `If` and never a competing outgoing one...
+ * splicing a new OUTGOING edge onto a bare branch owner is unsound (it always
+ * reads as a third lane, not a 'next step')". `findMoveUpSlot` and
+ * `findIndentSlot` do NOT guarantee a source-only slot (they can return a
+ * splice or a prepend, both of which hand the owner a new outgoing edge), so
+ * "Move up" and "Move into previous step" are just as unsound as Outdent for
+ * these nodes -- not merely "no genuine forward step" (which already,
+ * separately, makes `findMoveDownSlot` naturally return `undefined` with no
+ * extra gate needed). Disabling all four is the only interpretation that
+ * can't corrupt the graph.
+ */
+export function isBareBranchOwner(
+ projection: SequenceProjection,
+ nodeId: string,
+ isContainerNode: (nodeId: string) => boolean
+): boolean {
+ const row = projection.rows.find((candidate) => candidate.nodeId === nodeId);
+ return !!row?.collapsible && !isContainerNode(nodeId);
+}
+
+/**
+ * Computes the four move candidates for `nodeId`. A bare branch owner (see
+ * {@link isBareBranchOwner}) gets all four disabled regardless of what the
+ * individual `find*Slot` helpers return.
+ */
+export function computeSequentialMoveOptions(
+ projection: SequenceProjection,
+ nodeId: string,
+ isContainerNode: (nodeId: string) => boolean
+): SequentialMoveOptions {
+ if (isBareBranchOwner(projection, nodeId, isContainerNode)) {
+ return { up: undefined, down: undefined, indent: undefined, outdent: undefined };
+ }
+ const row = projection.rows.find((candidate) => candidate.nodeId === nodeId);
+ const ownerId = row?.parentRowId;
+ const ownerIsBareBranch = ownerId
+ ? isBareBranchOwner(projection, ownerId, isContainerNode)
+ : false;
+ return {
+ up: findMoveUpSlot(projection, nodeId),
+ down: findMoveDownSlot(projection, nodeId),
+ indent: findIndentSlot(projection, nodeId),
+ // A branch lane has no single semantic "after owner" seam. Synthesizing
+ // owner.output would create a new branch instead of moving the row out.
+ outdent: ownerIsBareBranch ? undefined : findOutdentSlot(projection, nodeId),
+ };
+}
+
+/** A loop-body tail cannot be outdented by splicing its close edge as a forward seam. */
+export function closesLoopToOwner(
+ projection: SequenceProjection,
+ nodeId: string,
+ edges: readonly Edge[]
+): boolean {
+ const ownerId = projection.rows.find((row) => row.nodeId === nodeId)?.parentRowId;
+ return !!ownerId && edges.some((edge) => edge.source === nodeId && edge.target === ownerId);
+}
+
+/** Reads the slot for a single direction (used by the keyboard handler). */
+export function getSequentialMoveSlot(
+ options: SequentialMoveOptions,
+ direction: SequentialMoveDirection
+): InsertionSlot | undefined {
+ switch (direction) {
+ case 'up':
+ return options.up;
+ case 'down':
+ return options.down;
+ case 'indent':
+ return options.indent;
+ case 'outdent':
+ return options.outdent;
+ }
+}
+
+/**
+ * Re-resolves a synthesized slot's SOURCE handle against the registry before
+ * committing (engine contract): `slotNavigation.ts`'s fallback slots stamp the
+ * generic `DEFAULT_SOURCE_HANDLE_ID` ('output') when there is no real edge to
+ * read a handle id from. When the registry knows the source node's actual
+ * default source handle (which may differ, e.g. a node type with no literal
+ * "output" handle id), that real id is used instead. A no-op when the slot's
+ * source handle is already something else (a real handle id copied from an
+ * existing edge), or when the registry has nothing better to offer.
+ */
+export function resolveSlotForCommit(
+ slot: InsertionSlot,
+ nodesById: ReadonlyMap,
+ getDefaultSourceHandleId: (nodeType: string) => string | undefined
+): InsertionSlot {
+ if (!slot.source || slot.source.handleId !== DEFAULT_SOURCE_HANDLE_ID) return slot;
+ const sourceType = nodesById.get(slot.source.nodeId)?.type;
+ if (!sourceType) return slot;
+ const resolved = getDefaultSourceHandleId(sourceType);
+ if (!resolved || resolved === DEFAULT_SOURCE_HANDLE_ID) return slot;
+ return { ...slot, source: { ...slot.source, handleId: resolved } };
+}
+
+/** The graph shape `moveSubtree` needs for cross-container `parentId` rewrites. */
+export interface CanonicalGraph {
+ nodes: Node[];
+ edges: Edge[];
+}
+
+/** Builds the terminal append slot without assuming a literal `output` handle. */
+export function resolveTailInsertionSlot(
+ projection: SequenceProjection | null,
+ nodes: readonly N[],
+ getDefaultSourceHandleId: (nodeType: string) => string | undefined,
+ isStartNode?: (node: N) => boolean
+): InsertionSlot | undefined {
+ const topRows = projection?.rows.filter((row) => row.depth === 0 && row.visible && !row.orphan);
+ const last = topRows?.[topRows.length - 1];
+ // Start nodes are folded into the synthetic "Workflow start" row. When the
+ // graph contains only one such node, use it as the terminal append source so
+ // the otherwise-empty "Add step" row still opens a valid insertion.
+ const startNodes = !last && isStartNode ? nodes.filter(isStartNode) : [];
+ const loneStart = startNodes.length === 1 ? startNodes[0] : undefined;
+ const sourceNode = last ? nodes.find((node) => node.id === last.nodeId) : loneStart;
+ if (!sourceNode) return undefined;
+ const nodeType = sourceNode.type;
+ return {
+ id: `slot:tail:${sourceNode.id}`,
+ source: {
+ nodeId: sourceNode.id,
+ handleId:
+ (nodeType ? getDefaultSourceHandleId(nodeType) : undefined) ?? DEFAULT_SOURCE_HANDLE_ID,
+ },
+ };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts
new file mode 100644
index 000000000..ed85225d1
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.test.ts
@@ -0,0 +1,100 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert';
+import { synthesizePositionsForFlow } from './synthesizePositionsForFlow';
+
+function boxesOverlap(a: Node, b: Node): boolean {
+ const aw = a.width ?? 96;
+ const ah = a.height ?? 96;
+ const bw = b.width ?? 96;
+ const bh = b.height ?? 96;
+ return (
+ a.position.x < b.position.x + bw &&
+ a.position.x + aw > b.position.x &&
+ a.position.y < b.position.y + bh &&
+ a.position.y + ah > b.position.y
+ );
+}
+
+function makeNode(id: string, x: number, y: number, extra: Partial = {}): Node {
+ return {
+ id,
+ type: 'uipath.script',
+ position: { x, y },
+ data: { display: { label: id } },
+ ...extra,
+ };
+}
+
+describe('synthesizePositionsForFlow', () => {
+ it('returns the same array reference when nothing was inserted', () => {
+ const nodes = [makeNode('a', 0, 0), makeNode('b', 0, 200)];
+ const result = synthesizePositionsForFlow(nodes, []);
+ expect(result).toBe(nodes);
+ });
+
+ it('places an inserted node without overlap and clears the sequential markers', () => {
+ const a = makeNode('a', 0, 0, { width: 96, height: 96 });
+ const inserted: Node = {
+ id: 'n',
+ type: 'uipath.slack',
+ position: { x: 0, y: 0 },
+ draggable: false,
+ data: { display: { label: 'Slack' }, [SEQ_INSERTED_FLAG]: true },
+ };
+ const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }];
+
+ const result = synthesizePositionsForFlow([a, inserted], edges);
+ const placed = result.find((node) => node.id === 'n')!;
+
+ // Placed to the right of its source, no overlap with A.
+ expect(placed.position.x).toBeGreaterThan(a.position.x);
+ expect(boxesOverlap(placed, a)).toBe(false);
+ // Markers cleared.
+ expect((placed.data as Record)[SEQ_INSERTED_FLAG]).toBeUndefined();
+ expect(placed.draggable).toBeUndefined();
+ // Original label data preserved.
+ expect((placed.data as { display: { label: string } }).display.label).toBe('Slack');
+ });
+
+ it('never moves a pre-existing node (returns them by identity)', () => {
+ const a = makeNode('a', 0, 0);
+ const b = makeNode('b', 500, 500);
+ const inserted: Node = {
+ id: 'n',
+ type: 'uipath.script',
+ position: { x: 0, y: 0 },
+ data: { [SEQ_INSERTED_FLAG]: true },
+ };
+ const edges: Edge[] = [{ id: 'e', source: 'a', target: 'n' }];
+
+ const result = synthesizePositionsForFlow([a, b, inserted], edges);
+
+ expect(result.find((n) => n.id === 'a')).toBe(a);
+ expect(result.find((n) => n.id === 'b')).toBe(b);
+ });
+
+ it('separates multiple inserted nodes so none overlap', () => {
+ const a = makeNode('a', 0, 0, { width: 96, height: 96 });
+ const makeInserted = (id: string): Node => ({
+ id,
+ type: 'uipath.script',
+ position: { x: 0, y: 0 },
+ data: { [SEQ_INSERTED_FLAG]: true },
+ });
+ // Both inserted nodes have the same source, so they anchor to the same spot
+ // and must be pushed apart.
+ const edges: Edge[] = [
+ { id: 'e1', source: 'a', target: 'n1' },
+ { id: 'e2', source: 'a', target: 'n2' },
+ ];
+
+ const result = synthesizePositionsForFlow([a, makeInserted('n1'), makeInserted('n2')], edges);
+ const n1 = result.find((n) => n.id === 'n1')!;
+ const n2 = result.find((n) => n.id === 'n2')!;
+
+ expect(boxesOverlap(n1, n2)).toBe(false);
+ expect(boxesOverlap(n1, a)).toBe(false);
+ expect(boxesOverlap(n2, a)).toBe(false);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts
new file mode 100644
index 000000000..89a678e58
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/synthesizePositionsForFlow.ts
@@ -0,0 +1,95 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_NODE_SIZE, GRID_SPACING } from '../../constants';
+import { getNonOverlappingPositionForDirection, snapToGrid } from '../../utils/NodeUtils';
+import { SEQ_INSERTED_FLAG } from './edges/sequentialInsert';
+
+/**
+ * Places nodes that were inserted while in the sequential view (D4).
+ *
+ * Sequential view never persists positions: layout owns them, and canonical
+ * `node.position` is left untouched so toggling back to flow is lossless. The
+ * one exception is a node the user added while in sequential view, which has no
+ * meaningful flow position. Those are stamped `data.seqInserted` (by
+ * `sequentialOnBeforeNodeAdded`); on toggle back to flow this function gives each
+ * one a real, non-overlapping position and clears the flag.
+ *
+ * Guarantees (asserted by the round-trip test):
+ * - NO pre-existing node is moved. Existing nodes are only read as obstacles;
+ * their objects are returned by identity. Only `seqInserted` nodes change.
+ * - No inserted node overlaps another node. Placement uses
+ * `getNonOverlappingPositionForDirection`, which shifts ONLY the new node
+ * (never the obstacles) until it clears them — the opposite of
+ * `resolveCollisions`, which would move both and violate the guarantee.
+ *
+ * Placement anchors each inserted node just to the right of its seam source (the
+ * node its incoming edge comes from), matching the left-to-right bias of the
+ * free-form Add Node pipeline, then resolves overlap by shifting downward.
+ * Obstacle checks stay within the node's own coordinate frame (same `parentId`),
+ * so a node inserted inside a container is placed relative to its siblings.
+ */
+export function synthesizePositionsForFlow(nodes: Node[], edges: Edge[]): Node[] {
+ const hasInserted = nodes.some((node) => isSeqInserted(node));
+ if (!hasInserted) return nodes;
+
+ const gap = GRID_SPACING * 5;
+ const result = [...nodes];
+ const indexById = new Map(result.map((node, index) => [node.id, index]));
+ const incomingByTarget = new Map();
+ for (const edge of edges) {
+ if (!incomingByTarget.has(edge.target)) incomingByTarget.set(edge.target, edge);
+ }
+
+ for (let i = 0; i < result.length; i++) {
+ const node = result[i]!;
+ if (!isSeqInserted(node)) continue;
+
+ const size = {
+ width: node.width ?? node.measured?.width ?? DEFAULT_NODE_SIZE,
+ height: node.height ?? node.measured?.height ?? DEFAULT_NODE_SIZE,
+ };
+
+ const sourceEdge = incomingByTarget.get(node.id);
+ const sourceIndex = sourceEdge ? indexById.get(sourceEdge.source) : undefined;
+ const source = sourceIndex !== undefined ? result[sourceIndex] : undefined;
+ const sourceWidth = source?.width ?? source?.measured?.width ?? DEFAULT_NODE_SIZE;
+
+ const anchor = source
+ ? { x: source.position.x + sourceWidth + gap, y: source.position.y }
+ : { x: 0, y: 0 };
+
+ // Obstacles: every other node sharing this node's coordinate frame. Same
+ // frame => comparing raw `position` is valid; nodes in other frames are
+ // irrelevant to overlap here.
+ const obstacles = result.filter(
+ (other) =>
+ other.id !== node.id && (other.parentId ?? undefined) === (node.parentId ?? undefined)
+ );
+
+ const placed = getNonOverlappingPositionForDirection(
+ obstacles,
+ { x: snapToGrid(anchor.x), y: snapToGrid(anchor.y) },
+ size,
+ 'right',
+ GRID_SPACING * 2
+ );
+
+ result[i] = clearInsertedMarker(node, placed);
+ }
+
+ return result;
+}
+
+function isSeqInserted(node: Node): boolean {
+ return (node.data as Record | undefined)?.[SEQ_INSERTED_FLAG] === true;
+}
+
+/**
+ * Returns a node with a real flow position and the sequential-only markers
+ * removed: the `seqInserted` flag is dropped and `draggable` (forced false by the
+ * sequential insert helper) is cleared so the node behaves normally in flow view.
+ */
+function clearInsertedMarker(node: Node, position: { x: number; y: number }): Node {
+ const { [SEQ_INSERTED_FLAG]: _flag, ...restData } = node.data as Record;
+ const { draggable: _draggable, ...restNode } = node;
+ return { ...restNode, position, data: restData };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts
new file mode 100644
index 000000000..cc9097818
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewMode.ts
@@ -0,0 +1,26 @@
+import { useCallback } from 'react';
+import { useStorageState } from '../../hooks/useStorageState';
+import type { CanvasView } from '../../utils/sequential/sequential.types';
+
+/**
+ * Persists the flow/sequential view choice per canvas (D11), wrapping the
+ * existing {@link useStorageState} localStorage helper. The host passes a stable
+ * `storageKey` (typically per-canvas); the choice survives reloads. Open product
+ * question Q4 notes a host-owned preference service can replace this later
+ * without touching the view components.
+ */
+export function useCanvasViewMode(
+ storageKey: string,
+ initial: CanvasView = 'flow'
+): [CanvasView, (view: CanvasView) => void] {
+ const [view, setStoredView] = useStorageState(storageKey, initial);
+
+ const setView = useCallback(
+ (next: CanvasView) => {
+ setStoredView(next);
+ },
+ [setStoredView]
+ );
+
+ return [view, setView];
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx
new file mode 100644
index 000000000..42ac9cb34
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.test.tsx
@@ -0,0 +1,94 @@
+import { act, renderHook } from '@testing-library/react';
+import type { Viewport } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it, vi } from 'vitest';
+import { useCanvasViewViewport } from './useCanvasViewViewport';
+
+const sequentialViewport: Viewport = { x: 10, y: 20, zoom: 0.8 };
+const flowViewport: Viewport = { x: 200, y: 100, zoom: 1.2 };
+
+describe('useCanvasViewViewport', () => {
+ it('fits an initial view once when requested', () => {
+ const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ });
+ const cancelAnimationFrame = vi.fn();
+ vi.stubGlobal('requestAnimationFrame', requestAnimationFrame);
+ vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame);
+
+ const reactFlow = {
+ getViewport: vi.fn(() => sequentialViewport),
+ setViewport: vi.fn(),
+ fitView: vi.fn(),
+ };
+ const { rerender, unmount } = renderHook(
+ ({ view }: { view: 'flow' | 'sequential' }) =>
+ useCanvasViewViewport({ view, reactFlow, fitOnMount: true }),
+ { initialProps: { view: 'sequential' as const } }
+ );
+
+ expect(reactFlow.fitView).toHaveBeenCalledOnce();
+ expect(reactFlow.fitView).toHaveBeenCalledWith({ duration: 0 });
+
+ rerender({ view: 'sequential' });
+ expect(reactFlow.fitView).toHaveBeenCalledOnce();
+
+ unmount();
+ vi.unstubAllGlobals();
+ });
+
+ it('restores each local viewport instead of fitting again on a return visit', () => {
+ let currentViewport = sequentialViewport;
+ const reactFlow = {
+ getViewport: vi.fn(() => currentViewport),
+ setViewport: vi.fn(),
+ fitView: vi.fn(),
+ };
+ const { result, rerender } = renderHook(
+ ({ view }: { view: 'flow' | 'sequential' }) => useCanvasViewViewport({ view, reactFlow }),
+ { initialProps: { view: 'sequential' as const } }
+ );
+
+ act(() => result.current.onMove?.(null, sequentialViewport));
+ rerender({ view: 'flow' });
+ expect(reactFlow.fitView).toHaveBeenCalledTimes(1);
+ expect(reactFlow.fitView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ minZoom: sequentialViewport.zoom,
+ maxZoom: sequentialViewport.zoom,
+ })
+ );
+
+ currentViewport = flowViewport;
+ act(() => result.current.onMove?.(null, flowViewport));
+ rerender({ view: 'sequential' });
+
+ expect(reactFlow.setViewport).toHaveBeenLastCalledWith(
+ { ...sequentialViewport, zoom: flowViewport.zoom },
+ { duration: 300 }
+ );
+ expect(reactFlow.fitView).toHaveBeenCalledTimes(1);
+ });
+
+ it('synchronizes the local fallback with an external viewport store', () => {
+ const stored = new Map<'flow' | 'sequential', Viewport>();
+ const externalStore = {
+ saveViewport: vi.fn((view: 'flow' | 'sequential', viewport: Viewport) => {
+ stored.set(view, viewport);
+ }),
+ getViewport: vi.fn((view: 'flow' | 'sequential') => stored.get(view)),
+ };
+ const reactFlow = {
+ getViewport: vi.fn(() => sequentialViewport),
+ setViewport: vi.fn(),
+ fitView: vi.fn(),
+ };
+ const { result } = renderHook(() =>
+ useCanvasViewViewport({ view: 'sequential', reactFlow, externalStore })
+ );
+
+ act(() => result.current.onMove?.(null, sequentialViewport));
+
+ expect(externalStore.saveViewport).toHaveBeenCalledWith('sequential', sequentialViewport);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts
new file mode 100644
index 000000000..0fb4cb01c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useCanvasViewViewport.ts
@@ -0,0 +1,108 @@
+import type {
+ Edge,
+ FitViewOptions,
+ Node,
+ OnMove,
+ ReactFlowInstance,
+ Viewport,
+} from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useEffect, useRef } from 'react';
+import type { CanvasView } from '../../utils/sequential';
+
+interface ExternalViewportStore {
+ saveViewport: (view: CanvasView, viewport: Viewport) => void;
+ getViewport: (view: CanvasView) => Viewport | undefined;
+}
+
+interface UseCanvasViewViewportArgs {
+ view: CanvasView;
+ reactFlow: Pick, 'fitView' | 'getViewport' | 'setViewport'>;
+ externalStore?: ExternalViewportStore;
+ fitViewOptions?: FitViewOptions;
+ fitOnMount?: boolean;
+}
+
+interface UseCanvasViewViewportResult {
+ defaultViewport?: Viewport;
+ onMove: OnMove;
+}
+
+/**
+ * Preserves an independent viewport for each presentation, even when the
+ * optional persisted SequentialViewProvider is absent.
+ */
+export function useCanvasViewViewport({
+ view,
+ reactFlow,
+ externalStore,
+ fitViewOptions,
+ fitOnMount = false,
+}: UseCanvasViewViewportArgs): UseCanvasViewViewportResult {
+ const localViewportByView = useRef>(new Map());
+ const previousView = useRef(view);
+ const initialized = useRef(false);
+
+ const saveViewport = useCallback(
+ (forView: CanvasView, viewport: Viewport) => {
+ localViewportByView.current.set(forView, viewport);
+ externalStore?.saveViewport(forView, viewport);
+ },
+ [externalStore]
+ );
+
+ const getViewport = useCallback(
+ (forView: CanvasView) =>
+ externalStore?.getViewport(forView) ?? localViewportByView.current.get(forView),
+ [externalStore]
+ );
+
+ const onMove = useCallback(
+ (_event, viewport) => saveViewport(view, viewport),
+ [saveViewport, view]
+ );
+
+ const defaultViewport = getViewport(view);
+
+ useEffect(() => {
+ if (initialized.current) return;
+
+ // Sequential nodes already have deterministic positions and dimensions, so
+ // the viewport hook only needs to fit them once after XYFlow commits them.
+ // From then on, onMove and the view-change effect own viewport persistence.
+ if (!fitOnMount || defaultViewport) {
+ initialized.current = true;
+ return;
+ }
+
+ const animationFrame = requestAnimationFrame(() => {
+ initialized.current = true;
+ void reactFlow.fitView({ ...fitViewOptions, duration: 0 });
+ });
+ return () => cancelAnimationFrame(animationFrame);
+ }, [defaultViewport, fitOnMount, fitViewOptions, reactFlow]);
+
+ useEffect(() => {
+ if (previousView.current === view) return;
+
+ // The node arrays have changed by the time this effect runs, but XYFlow's
+ // viewport has not. Capture it for the presentation we just left before
+ // restoring or initially fitting the new one.
+ const departingViewport = reactFlow.getViewport();
+ saveViewport(previousView.current, departingViewport);
+ previousView.current = view;
+
+ const saved = getViewport(view);
+ if (saved) {
+ void reactFlow.setViewport({ ...saved, zoom: departingViewport.zoom }, { duration: 300 });
+ } else {
+ void reactFlow.fitView({
+ ...fitViewOptions,
+ minZoom: departingViewport.zoom,
+ maxZoom: departingViewport.zoom,
+ duration: 300,
+ });
+ }
+ }, [view, reactFlow, fitViewOptions, getViewport, saveViewport]);
+
+ return { defaultViewport, onMove };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts
new file mode 100644
index 000000000..c384bd7d9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.test.ts
@@ -0,0 +1,642 @@
+import { renderHook } from '@testing-library/react';
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ PREVIEW_NODE_ID,
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+ SEQ_INDENT_PX,
+ SEQ_PLACEHOLDER_NODE_TYPE,
+ SEQ_ROW_GAP,
+ SEQ_START_NODE_TYPE,
+} from '../../constants';
+import {
+ makeDeepNestingFixture,
+ makeOrphanFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from '../../utils/sequential/fixtures';
+import { SEQUENTIAL_BAR_HANDLE_IDS } from './nodes';
+import {
+ SEQ_CONNECTOR_EDGE_TYPE,
+ SEQ_PLACEHOLDER_ROW_ID,
+ SEQ_START_ROW_ID,
+} from './sequentialGraph.constants';
+import { deriveSequentialGraph, useSequentialGraph } from './useSequentialGraph';
+
+describe('deriveSequentialGraph', () => {
+ it('clones every row keeping the real type, bar width, and non-draggable flag', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+
+ // 7 real rows + 2 populated-branch placeholders + synthetic start/tail.
+ expect(graph.nodes).toHaveLength(11);
+ expect(graph.nodes[0]?.id).toBe(SEQ_START_ROW_ID);
+ expect(graph.nodes[0]?.type).toBe(SEQ_START_NODE_TYPE);
+ expect(graph.nodes.some((node) => node.id === WIREFRAME_NODE_IDS.trigger)).toBe(false);
+ expect(graph.nodes.at(-1)?.id).toBe(SEQ_PLACEHOLDER_ROW_ID);
+ expect(graph.nodes.at(-1)?.type).toBe(SEQ_PLACEHOLDER_NODE_TYPE);
+
+ const http = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ expect(http.type).toBe('uipath.http-request');
+ expect(http.width).toBe(SEQ_BAR_WIDTH);
+ expect(http.draggable).toBe(false);
+ expect(http.parentId).toBeUndefined();
+ });
+
+ // Regression (perf loop): every clone AND both synthetic rows must declare an
+ // explicit bar height matching BaseNode's bar `computedHeight`. Without it the
+ // controlled `nodes` array re-applies `height: undefined` each sync, BaseNode
+ // re-writes it via updateNode, and the two fight through updateNodeInternals --
+ // a loop that blows React's nested-update limit at ~150 nodes.
+ it('stamps every row with an explicit height so the store height never churns', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+
+ for (const node of graph.nodes) {
+ // Append plus overlays are layout-neutral (row-gap tall, no reserved row);
+ // every other row declares the fixed bar height. Both are explicit, which
+ // is what keeps the controlled store height from churning.
+ const isAppendOverlay = (node.data as { variant?: string } | undefined)?.variant === 'plus';
+ expect(node.height).toBe(isAppendOverlay ? SEQ_ROW_GAP : SEQ_BAR_HEIGHT);
+ }
+ });
+
+ it('keeps clickable synthetic rows hit-testable by ReactFlow', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ onAddTrigger: () => {},
+ onPlaceholderAdd: () => {},
+ });
+
+ expect(graph.nodes.find((node) => node.id === SEQ_START_ROW_ID)?.selectable).toBe(true);
+ expect(graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.selectable).toBe(true);
+ });
+
+ it('renders a populated lane tail as a plus placeholder wired to the same onLaneAdd path', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const onLaneAdd = vi.fn();
+ const graph = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ onLaneAdd,
+ });
+ const slotId = `slot:leaf:${WIREFRAME_NODE_IDS.thenJs}`;
+ const placeholder = graph.nodes.find(
+ (node) => (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId === slotId
+ );
+
+ expect(placeholder).toMatchObject({
+ type: SEQ_PLACEHOLDER_NODE_TYPE,
+ selectable: true,
+ });
+ // The trailing add point of a populated lane renders as the between-step
+ // plus, not the dashed row an empty lane uses.
+ expect((placeholder?.data as { variant?: string } | undefined)?.variant).toBe('plus');
+ const onAdd = (placeholder?.data as { onAdd?: () => void } | undefined)?.onAdd;
+ expect(onAdd).toBeTypeOf('function');
+ onAdd?.();
+ expect(onLaneAdd).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: slotId,
+ source: { nodeId: WIREFRAME_NODE_IDS.thenJs },
+ containerId: WIREFRAME_NODE_IDS.forEach,
+ })
+ );
+ const join = graph.edges.find(
+ (edge) => edge.source === WIREFRAME_NODE_IDS.thenJs && edge.target === placeholder?.id
+ );
+ expect(join).toBeDefined();
+ // No arrowhead into an add affordance.
+ expect((join?.data as { hideArrowHead?: boolean } | undefined)?.hideArrowHead).toBe(true);
+ });
+
+ it('renders the terminal tail as a plus when there are steps, and a dashed row when empty', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const populated = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ onPlaceholderAdd: () => {},
+ });
+ const populatedTail = populated.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID);
+ expect((populatedTail?.data as { variant?: string } | undefined)?.variant).toBe('plus');
+
+ const empty = deriveSequentialGraph({
+ nodes: [],
+ edges: [],
+ view: 'sequential',
+ onPlaceholderAdd: () => {},
+ });
+ const emptyTail = empty.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID);
+ expect((emptyTail?.data as { variant?: string } | undefined)?.variant).toBe('row');
+ });
+
+ it('renders an empty container body as a dashed Add step row, not a plus', () => {
+ const nodes: Node[] = [{ id: 'loop', type: 'loop', position: { x: 0, y: 0 }, data: {} }];
+ const graph = deriveSequentialGraph({
+ nodes,
+ edges: [],
+ view: 'sequential',
+ isContainerNode: (node) => node.id === 'loop',
+ onLaneAdd: () => {},
+ });
+ const bodyPlaceholder = graph.nodes.find((node) =>
+ (node.data as { insertionSlotId?: string } | undefined)?.insertionSlotId?.startsWith(
+ 'slot:lane:loop'
+ )
+ );
+ expect(bodyPlaceholder).toBeDefined();
+ expect((bodyPlaceholder?.data as { variant?: string } | undefined)?.variant).toBe('row');
+ });
+
+ it('flattens container children and indents them by depth', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+
+ // The If is a body child of For Each -> depth 1 -> x = 1 * indent, parentId cleared.
+ const ifNode = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)!;
+ expect(ifNode.parentId).toBeUndefined();
+ expect(ifNode.position.x).toBe(SEQ_INDENT_PX);
+ });
+
+ it('applies custom node dimensions, indentation, and vertical gap to the real layout', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const customWidth = 640;
+ const customHeight = 80;
+ const customIndent = 128;
+ const customGap = 80;
+ const graph = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ layoutOptions: {
+ barWidth: customWidth,
+ barHeight: customHeight,
+ indent: customIndent,
+ rowGap: customGap,
+ },
+ });
+
+ expect(graph.nodes.every((node) => node.width === customWidth)).toBe(true);
+ expect(graph.nodes.every((node) => node.height === customHeight)).toBe(true);
+ expect(graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.ifNode)?.position.x).toBe(
+ customIndent
+ );
+ const httpY = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)?.position.y ?? 0;
+ const javascriptY =
+ graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.javascript)?.position.y ?? 0;
+ expect(javascriptY - httpY).toBe(customHeight + customGap);
+ });
+
+ it('preserves the canonical data reference and selection passthrough', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const selected = nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.http ? { ...node, selected: true } : node
+ );
+ const canonicalHttp = selected.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ const graph = deriveSequentialGraph({ nodes: selected, edges, view: 'sequential' });
+
+ const http = graph.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ expect(http.data).toBe(canonicalHttp.data);
+ expect(http.selected).toBe(true);
+ });
+
+ it('builds connector edges with the bar handle ids plus synthetic joins', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+
+ for (const edge of graph.edges) {
+ expect(edge.type).toBe(SEQ_CONNECTOR_EDGE_TYPE);
+ expect(edge.sourceHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.source);
+ // Branch/container-entry connectors enter the child's mid-left; every
+ // other kind drops into the top handle.
+ expect(edge.targetHandle).toBe(
+ edge.data?.kind === 'branch-entry'
+ ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget
+ : SEQUENTIAL_BAR_HANDLE_IDS.target
+ );
+ }
+ // Every projection connector + 2 synthetic joins (start->first, last->placeholder).
+ expect(graph.edges).toHaveLength((graph.projection?.connectors.length ?? 0) + 2);
+ expect(graph.edges.some((edge) => edge.source === SEQ_START_ROW_ID)).toBe(true);
+ expect(graph.edges.some((edge) => edge.target === SEQ_PLACEHOLDER_ROW_ID)).toBe(true);
+
+ const startEdge = graph.edges.find((edge) => edge.source === SEQ_START_ROW_ID);
+ expect(startEdge?.data?.hideArrowHead).toBe(false);
+ expect(startEdge?.data?.slot).toEqual({
+ id: `slot:head:${WIREFRAME_NODE_IDS.http}`,
+ target: { nodeId: WIREFRAME_NODE_IDS.http },
+ });
+ });
+
+ it('renders a split preview with the same projected connectors as the committed row', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const slot = base.projection?.connectors.find(
+ (connector) =>
+ connector.sourceRowId === WIREFRAME_NODE_IDS.http &&
+ connector.targetRowId === WIREFRAME_NODE_IDS.javascript
+ )?.slot;
+ expect(slot).toBeDefined();
+
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot });
+ const previewEdges = graph.edges.filter(
+ (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID
+ );
+
+ expect(previewEdges).toHaveLength(2);
+ expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true);
+ expect(previewEdges.every((edge) => edge.data?.slot === undefined)).toBe(true);
+ expect(previewEdges.every((edge) => edge.style?.opacity === 0.8)).toBe(true);
+ });
+
+ it('opens a real gap before the first row without moving the start bar into the preview', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const startEdge = base.edges.find((edge) => edge.source === SEQ_START_ROW_ID);
+ const slot = startEdge?.data?.slot;
+ expect(slot).toBeDefined();
+
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot });
+ const start = graph.nodes.find((node) => node.id === SEQ_START_ROW_ID)!;
+ const previewY = graph.layout?.positions.get(PREVIEW_NODE_ID)?.y;
+ const firstY = graph.layout?.positions.get(WIREFRAME_NODE_IDS.http)?.y;
+
+ expect(previewY).toBeDefined();
+ expect(firstY).toBeDefined();
+ expect(start.position.y).toBe((previewY ?? 0) - (SEQ_BAR_HEIGHT + SEQ_ROW_GAP));
+ expect(firstY).toBe((previewY ?? 0) + SEQ_BAR_HEIGHT + SEQ_ROW_GAP);
+ expect(new Set([start.position.y, previewY, firstY]).size).toBe(3);
+ });
+
+ it('keeps a branch preview on the branch-entry elbow and left target handle', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const slot = base.projection?.connectors.find(
+ (connector) =>
+ connector.sourceRowId === WIREFRAME_NODE_IDS.ifNode &&
+ connector.targetRowId === WIREFRAME_NODE_IDS.thenJs
+ )?.slot;
+ expect(slot).toBeDefined();
+
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot });
+ const incoming = graph.edges.find((edge) => edge.target === PREVIEW_NODE_ID);
+
+ expect(incoming?.data?.kind).toBe('branch-entry');
+ expect(incoming?.targetHandle).toBe(SEQUENTIAL_BAR_HANDLE_IDS.branchTarget);
+ expect(incoming?.data?.waypoints).toHaveLength(2);
+ });
+
+ it('draws the tail preview through to the placeholder without materializing that visual join', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ insertSlot: {
+ id: 'slot:tail',
+ source: { nodeId: WIREFRAME_NODE_IDS.sendMessage, handleId: 'output' },
+ },
+ });
+ const placeholderJoin = graph.edges.find(
+ (edge) => edge.source === PREVIEW_NODE_ID && edge.target === SEQ_PLACEHOLDER_ROW_ID
+ );
+
+ expect(placeholderJoin).toMatchObject({
+ sourceHandle: 'output',
+ data: { preview: true, ignorePreviewConnection: true },
+ });
+ });
+
+ it('places the placeholder before de-emphasized orphan rows', () => {
+ const { nodes, edges } = makeOrphanFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ expect(graph.nodes.map((node) => node.id)).toEqual([
+ SEQ_START_ROW_ID,
+ 'a',
+ 'b',
+ SEQ_PLACEHOLDER_ROW_ID,
+ 'z',
+ ]);
+ expect(graph.nodes.at(-1)?.className).toContain('opacity-60');
+ expect(graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y).toBeLessThan(
+ graph.nodes.find((node) => node.id === 'z')?.position.y ?? 0
+ );
+ });
+
+ it('places the placeholder below a trailing container body, not inside it', () => {
+ // root -> c1 (container: c2 -> leaf). c1 is the last top-level row, but its
+ // body (c2, leaf and its branch-tail placeholder) extends below it; the
+ // terminal placeholder must sit under the whole stack.
+ const { nodes, edges } = makeDeepNestingFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const placeholderY =
+ graph.nodes.find((node) => node.id === SEQ_PLACEHOLDER_ROW_ID)?.position.y ?? 0;
+ const leafY = graph.nodes.find((node) => node.id === 'leaf')?.position.y ?? 0;
+ const c1Y = graph.nodes.find((node) => node.id === 'c1')?.position.y ?? 0;
+ expect(leafY).toBeGreaterThan(c1Y);
+ expect(placeholderY).toBeGreaterThan(leafY);
+ });
+
+ it('passes the canonical graph through unchanged in flow view', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const graph = deriveSequentialGraph({ nodes, edges, view: 'flow' });
+ // deriveSequentialGraph always projects; the hook is what short-circuits flow
+ // view. This asserts the projection still runs without throwing.
+ expect(graph.projection).not.toBeNull();
+ });
+
+ it('keeps preview edges on guaranteed bar handles while carrying canonical handles for filtering', () => {
+ const nodes: Node[] = [
+ { id: 'javascript', type: 'script', position: { x: 0, y: 0 }, data: {} },
+ { id: 'for-each', type: 'foreach', position: { x: 0, y: 100 }, data: {} },
+ ];
+ const edges: Edge[] = [
+ {
+ id: 'javascript-success-for-each',
+ source: 'javascript',
+ sourceHandle: 'success',
+ target: 'for-each',
+ targetHandle: 'input',
+ },
+ ];
+ const getBranchHandles = (node: Node) =>
+ node.id === 'javascript' ? [{ id: 'error', label: 'Error' }] : [];
+ const base = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ getBranchHandles,
+ });
+ const slot = base.projection?.connectors.find(
+ (connector) => connector.sourceRowId === 'javascript' && connector.targetRowId === 'for-each'
+ )?.slot;
+
+ expect(slot).toMatchObject({
+ source: { nodeId: 'javascript', handleId: 'success' },
+ target: { nodeId: 'for-each', handleId: 'input' },
+ });
+
+ const preview = deriveSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ getBranchHandles,
+ insertSlot: slot,
+ });
+ const incoming = preview.edges.find(
+ (edge) => edge.source === 'javascript' && edge.target === PREVIEW_NODE_ID
+ );
+ const outgoing = preview.edges.find(
+ (edge) => edge.source === PREVIEW_NODE_ID && edge.target === 'for-each'
+ );
+
+ expect(incoming).toMatchObject({
+ sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ targetHandle: 'input',
+ data: { previewConnectionHandleId: 'success' },
+ });
+ expect(outgoing).toMatchObject({
+ sourceHandle: 'output',
+ targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ data: { previewConnectionHandleId: 'input' },
+ });
+ });
+});
+
+describe('presentation-only node preservation', () => {
+ it('omits excluded nodes and their edges from sequential without mutating canonical state', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const sticky: Node = {
+ id: 'note',
+ type: 'stickyNote',
+ position: { x: 900, y: 700 },
+ data: { content: 'Keep me in Flow' },
+ };
+ const annotationEdge: Edge = {
+ id: 'note-http',
+ source: sticky.id,
+ target: WIREFRAME_NODE_IDS.http,
+ };
+
+ const graph = deriveSequentialGraph({
+ nodes: [...nodes, sticky],
+ edges: [...edges, annotationEdge],
+ view: 'sequential',
+ isSequenceNode: (node) => node.type !== 'stickyNote',
+ });
+
+ expect(graph.nodes.some((node) => node.id === sticky.id)).toBe(false);
+ expect(graph.edges.some((edge) => edge.id === annotationEdge.id)).toBe(false);
+ expect(sticky.position).toEqual({ x: 900, y: 700 });
+ expect(graph.projection?.rows.filter((row) => !row.lanePlaceholder)).toHaveLength(7);
+ });
+});
+
+describe('useSequentialGraph memoization (D12)', () => {
+ it('hands routed preview connectors to ReactFlow while an insertion slot is active', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const base = deriveSequentialGraph({ nodes, edges, view: 'sequential' });
+ const slot = base.projection?.connectors.find(
+ (connector) =>
+ connector.sourceRowId === WIREFRAME_NODE_IDS.http &&
+ connector.targetRowId === WIREFRAME_NODE_IDS.javascript
+ )?.slot;
+ expect(slot).toBeDefined();
+
+ const { result } = renderHook(() =>
+ useSequentialGraph({ nodes, edges, view: 'sequential', insertSlot: slot })
+ );
+ const previewEdges = result.current.edges.filter(
+ (edge) => edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID
+ );
+
+ expect(previewEdges).toHaveLength(2);
+ expect(previewEdges.every((edge) => edge.data?.preview === true)).toBe(true);
+ expect(result.current.layout?.positions.get(PREVIEW_NODE_ID)).toBeDefined();
+ });
+
+ it('reuses the connector-edge array across data-only changes but reflects new data on nodes', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result, rerender } = renderHook(
+ (props: { nodes: Node[]; edges: Edge[] }) =>
+ useSequentialGraph({ nodes: props.nodes, edges: props.edges, view: 'sequential' }),
+ { initialProps: { nodes, edges } }
+ );
+
+ const firstEdges = result.current.edges;
+ const firstNodes = result.current.nodes;
+
+ // Data-only change: rename one node (structure/fingerprint unchanged).
+ const renamed = nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.http
+ ? { ...node, data: { display: { label: 'Renamed' } } }
+ : node
+ );
+ rerender({ nodes: renamed, edges });
+
+ // Edges are reference-stable across data-only changes (D12).
+ expect(result.current.edges).toBe(firstEdges);
+ // Nodes recompute so the rename is reflected.
+ expect(result.current.nodes).not.toBe(firstNodes);
+ const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ expect((http.data as { display: { label: string } }).display.label).toBe('Renamed');
+ });
+
+ it('reuses every unchanged derived node across a single-node selection update', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result, rerender } = renderHook(
+ (props: { nodes: Node[] }) =>
+ useSequentialGraph({ nodes: props.nodes, edges, view: 'sequential' }),
+ { initialProps: { nodes } }
+ );
+ const firstNodesById = new Map(result.current.nodes.map((node) => [node.id, node]));
+ const selectedId = WIREFRAME_NODE_IDS.http;
+ const selected = nodes.map((node) =>
+ node.id === selectedId ? { ...node, selected: true } : node
+ );
+
+ rerender({ nodes: selected });
+
+ for (const node of result.current.nodes) {
+ if (node.id === selectedId) {
+ expect(node).not.toBe(firstNodesById.get(node.id));
+ expect(node.selected).toBe(true);
+ } else {
+ expect(node).toBe(firstNodesById.get(node.id));
+ }
+ }
+ });
+
+ it('reuses projection when equivalent registry predicates change identity on rename', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result, rerender } = renderHook(
+ (props: { nodes: Node[]; predicateVersion: number }) =>
+ useSequentialGraph({
+ nodes: props.nodes,
+ edges,
+ view: 'sequential',
+ isSequenceEdge: (edge) => !!edge.id && props.predicateVersion >= 0,
+ isStartNode: (node) => node.type === 'uipath.first-run' && props.predicateVersion >= 0,
+ resolveBranchLabel: (_nodeId, handleId) =>
+ props.predicateVersion >= 0 ? handleId : handleId,
+ }),
+ { initialProps: { nodes, predicateVersion: 0 } }
+ );
+ const firstProjection = result.current.projection;
+ const renamed = nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.http
+ ? { ...node, data: { display: { label: 'Renamed' } } }
+ : node
+ );
+
+ rerender({ nodes: renamed, predicateVersion: 1 });
+ expect(result.current.projection).toBe(firstProjection);
+ });
+
+ it('rebuilds the connector-edge array on a structural change', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result, rerender } = renderHook(
+ (props: { nodes: Node[]; edges: Edge[] }) =>
+ useSequentialGraph({ nodes: props.nodes, edges: props.edges, view: 'sequential' }),
+ { initialProps: { nodes, edges } }
+ );
+ const firstEdges = result.current.edges;
+
+ // Structural change: drop an edge.
+ const fewerEdges = edges.slice(0, -1);
+ rerender({ nodes, edges: fewerEdges });
+
+ expect(result.current.edges).not.toBe(firstEdges);
+ });
+
+ it('refreshes connector labels when edge data changes without a topology change', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result, rerender } = renderHook(
+ (props: { edges: Edge[] }) =>
+ useSequentialGraph({ nodes, edges: props.edges, view: 'sequential' }),
+ { initialProps: { edges } }
+ );
+ const firstProjection = result.current.projection;
+ const relabeled = edges.map((edge) =>
+ edge.id === 'e-if-then' ? { ...edge, data: { ...edge.data, label: 'When true' } } : edge
+ );
+
+ rerender({ edges: relabeled });
+ expect(result.current.projection).not.toBe(firstProjection);
+ expect(
+ result.current.projection?.connectors.find(
+ (connector) =>
+ connector.sourceRowId === WIREFRAME_NODE_IDS.ifNode &&
+ connector.targetRowId === WIREFRAME_NODE_IDS.thenJs
+ )?.label
+ ).toBe('When true');
+ });
+
+ it('returns the canonical graph in flow view', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'flow' }));
+ expect(result.current.nodes).toBe(nodes);
+ expect(result.current.edges).toBe(edges);
+ expect(result.current.projection).toBeNull();
+ });
+
+ it('exposes the layout result so consumers (e.g. SequentialGutter) can read row positions', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' }));
+ expect(result.current.layout).not.toBeNull();
+ expect(result.current.layout?.positions.get(WIREFRAME_NODE_IDS.http)).toBeDefined();
+ });
+
+ it('returns layout: null in flow view', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'flow' }));
+ expect(result.current.layout).toBeNull();
+ });
+});
+
+describe('useSequentialGraph step aria labeling (D8)', () => {
+ it('stamps node.ariaLabel as "Step N of Total: Label" on every numbered row', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' }));
+
+ // The wireframe fixture has 7 numbered rows (see fixtures.ts's doc comment).
+ const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ expect(http.ariaLabel).toBe('Step 1 of 7: HTTP Request');
+
+ const javascript = result.current.nodes.find(
+ (node) => node.id === WIREFRAME_NODE_IDS.javascript
+ )!;
+ expect(javascript.ariaLabel).toBe('Step 2 of 7: Javascript');
+ });
+
+ it('does not stamp an ariaLabel on the synthetic start/placeholder rows', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() => useSequentialGraph({ nodes, edges, view: 'sequential' }));
+
+ expect(result.current.nodes[0]?.id).toBe(SEQ_START_ROW_ID);
+ expect(result.current.nodes[0]?.ariaLabel).toBeUndefined();
+ expect(result.current.nodes.at(-1)?.id).toBe(SEQ_PLACEHOLDER_ROW_ID);
+ expect(result.current.nodes.at(-1)?.ariaLabel).toBeUndefined();
+ });
+
+ it('keeps the total stable when a container collapses (D7 numbering stability)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const { result } = renderHook(() =>
+ useSequentialGraph({
+ nodes,
+ edges,
+ view: 'sequential',
+ collapsedStepIds: new Set([WIREFRAME_NODE_IDS.forEach]),
+ })
+ );
+
+ const http = result.current.nodes.find((node) => node.id === WIREFRAME_NODE_IDS.http)!;
+ expect(http.ariaLabel).toBe('Step 1 of 7: HTTP Request');
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts
new file mode 100644
index 000000000..a93f2bde1
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialGraph.ts
@@ -0,0 +1,762 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useMemo, useRef } from 'react';
+import { useSafeLingui } from '../../../i18n';
+import {
+ DEFAULT_SOURCE_HANDLE_ID,
+ DEFAULT_TARGET_HANDLE_ID,
+ PREVIEW_NODE_ID,
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+ SEQ_PLACEHOLDER_NODE_TYPE,
+ SEQ_ROW_GAP,
+ SEQ_START_NODE_TYPE,
+} from '../../constants';
+import type { NodeTypeRegistry } from '../../core';
+import { useOptionalNodeTypeRegistry } from '../../core';
+import type { InstanceDisplayConfig } from '../../schema/node-instance';
+import { resolveDisplay } from '../../utils/manifest-resolver';
+import { sequenceFingerprint } from '../../utils/sequential/fingerprint';
+import { layoutSequence } from '../../utils/sequential/layoutSequence';
+import { projectionWithPreviewRow } from '../../utils/sequential/previewRow';
+import { projectSequence } from '../../utils/sequential/projectSequence';
+import type {
+ CanvasView,
+ InsertionSlot,
+ LayoutSequenceOptions,
+ SequenceLayout,
+ SequenceProjection,
+} from '../../utils/sequential/sequential.types';
+import { EMPTY_WAYPOINTS } from '../Edges/shared/constants';
+import type { SequentialConnectorData } from './edges/SequentialConnectorEdge.types';
+import { SEQUENTIAL_BAR_HANDLE_IDS } from './nodes';
+import type { SequentialPlaceholderNodeData } from './nodes/SequentialPlaceholderNode';
+import type { SequentialStartNodeData } from './nodes/SequentialStartNode';
+import {
+ SEQ_CONNECTOR_EDGE_TYPE,
+ SEQ_PLACEHOLDER_EDGE_ID,
+ SEQ_PLACEHOLDER_ROW_ID,
+ SEQ_START_EDGE_ID,
+ SEQ_START_ROW_ID,
+} from './sequentialGraph.constants';
+
+/** The translate function `useSafeLingui()._` returns; used to type the aria-label formatter. */
+type TranslateFn = ReturnType['_'];
+
+const EMPTY_COLLAPSED: ReadonlySet = new Set();
+
+/**
+ * Stable string over a predicate applied to each item, used only as a memo
+ * guard so the projection recomputes when predicate *results* (not identity)
+ * change. When the predicate is absent, every item falls back to `whenAbsent`.
+ */
+function predicateFingerprint(
+ items: readonly T[],
+ predicate: ((item: T) => boolean) | undefined,
+ whenAbsent: 0 | 1
+): string {
+ return items
+ .map((item) => `${item.id}:${predicate ? (predicate(item) ? 1 : 0) : whenAbsent}`)
+ .join('|');
+}
+
+/** The ids of the two synthetic rows, for filtering them out of change callbacks. */
+export const SEQ_SYNTHETIC_ROW_IDS: ReadonlySet = new Set([
+ SEQ_START_ROW_ID,
+ SEQ_PLACEHOLDER_ROW_ID,
+]);
+
+export interface UseSequentialGraphArgs {
+ nodes: N[];
+ edges: E[];
+ /** Only the 'sequential' view derives; 'flow' passes the canonical graph through. */
+ view: CanvasView;
+ collapsedStepIds?: ReadonlySet;
+ /** Wired onto the synthetic start bar's "Add trigger" button. */
+ onAddTrigger?: () => void;
+ /** Wired onto the terminal placeholder's click (opens Add Node for the tail slot). */
+ onPlaceholderAdd?: () => void;
+ /** Registry-driven predicate excluding artifact/resource edges (see projectSequence). */
+ isSequenceEdge?: (edge: E) => boolean;
+ /** Excludes presentation-only nodes while retaining them in the canonical graph. */
+ isSequenceNode?: (node: N) => boolean;
+ /** Registry-driven trigger predicate; matching nodes collapse into the synthetic start row. */
+ isStartNode?: (node: N) => boolean;
+ /** Registry-driven predicate preserving empty manifest containers. */
+ isContainerNode?: (node: N) => boolean;
+ resolveBranchLabel?: (nodeId: string, handleId: string) => string;
+ /** Registry-driven branch-lane handles for a parent node (see projectSequence). */
+ getBranchHandles?: (node: N) => { id: string; label: string }[];
+ /** Opens the Add Node panel for an empty branch lane's "+ Add step" placeholder. */
+ onLaneAdd?: (slot: InsertionSlot) => void;
+ /**
+ * The slot whose Add Node panel is currently open, if any. While set, the
+ * layout re-runs with a synthetic preview row spliced in at the slot, opening
+ * a one-row gap and re-routing every connector from the shifted geometry. Only
+ * the layout memo depends on it; the projection memo stays fingerprint-keyed
+ * (D12), so this never re-projects.
+ */
+ insertSlot?: InsertionSlot;
+ /** Geometry overrides applied consistently to rows, connectors, gutter, and preview layout. */
+ layoutOptions?: LayoutSequenceOptions;
+}
+
+export interface SequentialGraph {
+ nodes: N[];
+ edges: E[];
+ projection: SequenceProjection | null;
+ /**
+ * The geometry pass backing `nodes`' positions. Exposed so consumers like
+ * `SequentialGutter` (WS5) can align the step-number gutter to the exact
+ * same coordinates without re-deriving them or reading node DOM.
+ */
+ layout: SequenceLayout | null;
+}
+
+/**
+ * Derives the sequential view's node/edge arrays from the canonical graph (D4):
+ * - each projected row becomes a clone of its canonical node that KEEPS its real
+ * `type` (so BaseNode resolves the real manifest and the bar looks identical to
+ * the card), takes its position from `layoutSequence`, is stamped
+ * `width = SEQ_BAR_WIDTH`, flattened (`parentId` cleared so the absolute layout
+ * positions are honored), made non-draggable, and hidden when a collapsed
+ * ancestor parks it. `data` and `selected` are passed through by reference so
+ * execution/validation contexts, memoization, and selection all keep working;
+ * - a synthetic "Workflow start" bar is injected above the first row and a
+ * terminal "Add step" placeholder below the last top-level row (both view-only,
+ * filtered from the change callbacks);
+ * - each projection connector becomes a reference-stable
+ * {@link SequentialConnectorData} edge, plus synthetic connectors joining the
+ * start/placeholder bars.
+ *
+ * This is a PURE function so the round-trip/derivation tests exercise it without
+ * mounting xyflow. The hook {@link useSequentialGraph} memoizes it per D12.
+ */
+export function deriveSequentialGraph(
+ args: UseSequentialGraphArgs
+): SequentialGraph {
+ const { nodes, edges } = args;
+ const collapsed = args.collapsedStepIds ?? EMPTY_COLLAPSED;
+ const sequenceNodes = args.isSequenceNode ? nodes.filter(args.isSequenceNode) : nodes;
+ const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id));
+ const sequenceEdges = edges.filter(
+ (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target)
+ );
+ const projection = projectSequence(sequenceNodes, sequenceEdges, {
+ collapsedStepIds: collapsed,
+ isSequenceEdge: args.isSequenceEdge as ((edge: Edge) => boolean) | undefined,
+ isStartNode: args.isStartNode as ((node: Node) => boolean) | undefined,
+ isContainerNode: args.isContainerNode as ((node: Node) => boolean) | undefined,
+ resolveBranchLabel: args.resolveBranchLabel,
+ getBranchHandles: args.getBranchHandles as
+ | ((node: Node) => { id: string; label: string }[])
+ | undefined,
+ });
+ const renderedProjection = args.insertSlot
+ ? projectionWithPreviewRow(projection, args.insertSlot)
+ : projection;
+ const layout = layoutSequence(renderedProjection, args.layoutOptions);
+
+ const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
+ const seqNodes = buildSequentialNodes(
+ projection,
+ layout,
+ nodesById,
+ {
+ onAddTrigger: args.onAddTrigger,
+ onPlaceholderAdd: args.onPlaceholderAdd,
+ onLaneAdd: args.onLaneAdd,
+ },
+ args.layoutOptions
+ );
+ const seqEdges = buildSequentialEdges(renderedProjection, layout);
+
+ return {
+ nodes: seqNodes as unknown as N[],
+ edges: seqEdges as unknown as E[],
+ projection,
+ layout,
+ };
+}
+
+interface SyntheticCallbacks {
+ onAddTrigger?: () => void;
+ onPlaceholderAdd?: () => void;
+ /** Wired onto each empty branch-lane placeholder's click, with its own slot. */
+ onLaneAdd?: (slot: InsertionSlot) => void;
+}
+
+function buildSequentialNodes(
+ projection: SequenceProjection,
+ layout: SequenceLayout,
+ nodesById: ReadonlyMap,
+ callbacks: SyntheticCallbacks,
+ layoutOptions?: LayoutSequenceOptions
+): Node[] {
+ const barWidth = layoutOptions?.barWidth ?? SEQ_BAR_WIDTH;
+ const barHeight = layoutOptions?.barHeight ?? SEQ_BAR_HEIGHT;
+ const rowGap = layoutOptions?.rowGap ?? SEQ_ROW_GAP;
+ const pitch = barHeight + rowGap;
+ const clones: Node[] = [];
+ const orphanClones: Node[] = [];
+ let firstTopY: number | undefined;
+ let firstOrphanY: number | undefined;
+ // Lowest visible, non-orphan row (any depth), so the terminal placeholder can
+ // sit below the WHOLE stack rather than just below the last top-level row.
+ let maxVisibleY: number | undefined;
+
+ for (const row of projection.rows) {
+ // Empty branch-lane placeholder: a synthetic dashed "+ Add step" bar with no
+ // canonical node. Rendered via the placeholder node type, entered mid-left by
+ // its branch-entry connector (Part A), and clicking it appends the first node
+ // into that lane via the carried slot.
+ if (row.lanePlaceholder) {
+ const laneSlot = row.lanePlaceholder;
+ const onLaneAdd = callbacks.onLaneAdd;
+ clones.push({
+ id: row.nodeId,
+ type: SEQ_PLACEHOLDER_NODE_TYPE,
+ position: layout.positions.get(row.nodeId) ?? { x: 0, y: 0 },
+ width: barWidth,
+ // An append plus is an overlay in the row gap (rowGap tall, no reserved
+ // row); an empty lane/body is a full dashed row.
+ height: row.placeholderKind === 'append' ? rowGap : barHeight,
+ draggable: false,
+ // ReactFlow sets pointer-events:none on wrappers that are neither
+ // selectable nor draggable and have no node-level click handler. Keep
+ // the row hit-testable whenever its inner Add-step button is active;
+ // that button stops propagation, so this does not select the row.
+ selectable: onLaneAdd !== undefined,
+ hidden: !row.visible,
+ data: {
+ onAdd: onLaneAdd ? () => onLaneAdd(laneSlot) : undefined,
+ insertionSlotId: laneSlot.id,
+ // Empty lane/body renders the dashed row; a populated lane's trailing
+ // add point renders the same quiet plus used between steps.
+ variant: row.placeholderKind === 'append' ? 'plus' : 'row',
+ } satisfies SequentialPlaceholderNodeData,
+ });
+ continue;
+ }
+ const canonical = nodesById.get(row.nodeId);
+ if (!canonical) continue;
+ const position = layout.positions.get(row.nodeId) ?? { x: 0, y: 0 };
+ const clone: Node = {
+ ...canonical,
+ position,
+ width: barWidth,
+ // Declare the layout-owned bar height up front so it matches BaseNode's
+ // bar `computedHeight`. Without it the controlled `nodes`
+ // array re-applies `height: undefined` on every sync, BaseNode's
+ // height write-back re-sets it via `updateNode`, and the two fight
+ // through `updateNodeInternals` -- a loop that self-settles at small
+ // graphs but blows React's nested-update limit at ~150 nodes.
+ height: barHeight,
+ draggable: false,
+ hidden: !row.visible,
+ // Flatten container nesting: layout positions are absolute, so a residual
+ // parentId would double-offset the clone.
+ parentId: undefined,
+ extent: undefined,
+ expandParent: undefined,
+ className: row.orphan
+ ? [canonical.className, 'opacity-60'].filter(Boolean).join(' ')
+ : canonical.className,
+ };
+ if (row.orphan) {
+ orphanClones.push(clone);
+ if (row.visible && firstOrphanY === undefined) firstOrphanY = position.y;
+ continue;
+ }
+ clones.push(clone);
+ if (row.depth === 0 && firstTopY === undefined) firstTopY = position.y;
+ if (row.visible) {
+ maxVisibleY = maxVisibleY === undefined ? position.y : Math.max(maxVisibleY, position.y);
+ }
+ }
+
+ // A laid-out preview row (Add Node panel open) can be the new lowest row on a
+ // tail append; keep the terminal placeholder below it too.
+ const previewY = layout.positions.get(PREVIEW_NODE_ID)?.y;
+ if (previewY !== undefined) {
+ maxVisibleY = maxVisibleY === undefined ? previewY : Math.max(maxVisibleY, previewY);
+ }
+
+ // Keep the synthetic start bar one pitch above the earliest rendered
+ // top-level row. During a head insertion the preview temporarily becomes
+ // that earliest row; deriving startY only from the first canonical row would
+ // move the start bar down into the preview's slot and stack the two nodes.
+ const earliestTopY = Math.min(firstTopY ?? Number.POSITIVE_INFINITY, previewY ?? Infinity);
+ const startY = (Number.isFinite(earliestTopY) ? earliestTopY : 0) - pitch;
+ // Below the ENTIRE visible stack (any depth), not just the last top-level row,
+ // so it never lands inside a trailing container's body and overlap a nested
+ // leaf's add affordance.
+ // A non-empty flow's tail is a plus overlay in the gap just below the last row
+ // (no reserved row); an empty flow shows the dashed "Add step" row as a full row.
+ const tailIsPlus = firstTopY !== undefined;
+ const placeholderY =
+ firstOrphanY !== undefined
+ ? firstOrphanY - pitch
+ : (maxVisibleY ?? -pitch) + (tailIsPlus ? barHeight : pitch);
+
+ const startNode: Node = {
+ id: SEQ_START_ROW_ID,
+ type: SEQ_START_NODE_TYPE,
+ position: { x: 0, y: startY },
+ width: barWidth,
+ height: barHeight,
+ draggable: false,
+ selectable: callbacks.onAddTrigger !== undefined,
+ data: { onAddTrigger: callbacks.onAddTrigger } satisfies SequentialStartNodeData,
+ };
+
+ const placeholderNode: Node = {
+ id: SEQ_PLACEHOLDER_ROW_ID,
+ type: SEQ_PLACEHOLDER_NODE_TYPE,
+ position: { x: 0, y: placeholderY },
+ width: barWidth,
+ height: tailIsPlus ? rowGap : barHeight,
+ draggable: false,
+ selectable: callbacks.onPlaceholderAdd !== undefined,
+ data: {
+ onAdd: callbacks.onPlaceholderAdd,
+ // With real steps above, the tail is a plus overlay in the gap (append); an
+ // empty flow shows the dashed "Add step" row instead.
+ variant: tailIsPlus ? 'plus' : 'row',
+ } satisfies SequentialPlaceholderNodeData,
+ };
+
+ return [startNode, ...clones, placeholderNode, ...orphanClones];
+}
+
+function buildSequentialEdges(projection: SequenceProjection, layout: SequenceLayout): Edge[] {
+ // A connector into a trailing insert (plus) placeholder must not draw an
+ // arrowhead: its target is an add affordance, not a real step.
+ const appendPlaceholderIds = new Set(
+ projection.rows.filter((row) => row.placeholderKind === 'append').map((row) => row.nodeId)
+ );
+ // A connector touching a row hidden by a collapsed ancestor must not offer an
+ // insert: you cannot see where the node would land.
+ const hiddenRowIds = new Set(
+ projection.rows.filter((row) => !row.visible).map((row) => row.nodeId)
+ );
+ const edges: Edge[] = projection.connectors.map((connector) => {
+ const isPreviewConnector =
+ connector.sourceRowId === PREVIEW_NODE_ID || connector.targetRowId === PREVIEW_NODE_ID;
+ const previewConnectionHandleId = !isPreviewConnector
+ ? undefined
+ : connector.sourceRowId === PREVIEW_NODE_ID
+ ? (connector.slot?.target?.handleId ?? DEFAULT_TARGET_HANDLE_ID)
+ : (connector.slot?.source?.handleId ?? DEFAULT_SOURCE_HANDLE_ID);
+ const data: SequentialConnectorData = {
+ kind: connector.kind,
+ label: connector.label,
+ waypoints: layout.connectorWaypoints.get(connector.id) ?? EMPTY_WAYPOINTS,
+ // The preview connectors retain the semantic slot for canonical handle
+ // resolution below, but must not offer another insert button while the
+ // Add Node panel is already open.
+ slot:
+ isPreviewConnector ||
+ hiddenRowIds.has(connector.targetRowId) ||
+ hiddenRowIds.has(connector.sourceRowId)
+ ? undefined
+ : connector.slot,
+ preview: isPreviewConnector || undefined,
+ previewConnectionHandleId,
+ hideArrowHead: connector.kind === 'goto' || appendPlaceholderIds.has(connector.targetRowId),
+ };
+ return {
+ id: connector.id,
+ source: connector.sourceRowId,
+ target: connector.targetRowId,
+ sourceHandle:
+ connector.sourceRowId === PREVIEW_NODE_ID ? 'output' : SEQUENTIAL_BAR_HANDLE_IDS.source,
+ // Branch/container-entry connectors enter the child's mid-left; every
+ // other kind drops into its top handle.
+ targetHandle:
+ connector.targetRowId === PREVIEW_NODE_ID
+ ? connector.kind === 'branch-entry'
+ ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget
+ : 'input'
+ : connector.kind === 'branch-entry'
+ ? SEQUENTIAL_BAR_HANDLE_IDS.branchTarget
+ : SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: SEQ_CONNECTOR_EDGE_TYPE,
+ data,
+ ...(isPreviewConnector
+ ? {
+ style: {
+ opacity: 0.8,
+ stroke: 'var(--canvas-selection-indicator)',
+ strokeWidth: 2,
+ },
+ }
+ : {}),
+ };
+ });
+
+ // Synthetic joins: start -> first top row, last top row -> placeholder. When
+ // there are no rows the two synthetic bars connect directly.
+ const topRows = projection.rows.filter((row) => row.depth === 0 && !row.orphan);
+ const firstTop = topRows[0];
+ const lastVisibleTop = [...topRows].reverse().find((row) => row.visible);
+
+ const syntheticData = (hideArrowHead = true): SequentialConnectorData => ({
+ kind: 'step',
+ waypoints: EMPTY_WAYPOINTS,
+ hideArrowHead,
+ });
+
+ const syntheticPreviewData = (hideArrowHead = true): SequentialConnectorData => ({
+ ...syntheticData(hideArrowHead),
+ preview: true,
+ ignorePreviewConnection: true,
+ });
+
+ const previewIsFirst = firstTop?.nodeId === PREVIEW_NODE_ID;
+ const headSlot: InsertionSlot | undefined =
+ firstTop && !previewIsFirst
+ ? { id: `slot:head:${firstTop.nodeId}`, target: { nodeId: firstTop.nodeId } }
+ : undefined;
+ edges.push({
+ id: SEQ_START_EDGE_ID,
+ source: SEQ_START_ROW_ID,
+ target: firstTop?.nodeId ?? SEQ_PLACEHOLDER_ROW_ID,
+ sourceHandle: SEQUENTIAL_BAR_HANDLE_IDS.source,
+ targetHandle: previewIsFirst ? 'input' : SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: SEQ_CONNECTOR_EDGE_TYPE,
+ data: previewIsFirst
+ ? syntheticPreviewData(false)
+ : { ...syntheticData(firstTop === undefined), slot: headSlot },
+ ...(previewIsFirst
+ ? {
+ style: {
+ opacity: 0.8,
+ stroke: 'var(--canvas-selection-indicator)',
+ strokeWidth: 2,
+ },
+ }
+ : {}),
+ });
+
+ if (lastVisibleTop) {
+ const previewIsLast = lastVisibleTop.nodeId === PREVIEW_NODE_ID;
+ edges.push({
+ id: SEQ_PLACEHOLDER_EDGE_ID,
+ source: lastVisibleTop.nodeId,
+ target: SEQ_PLACEHOLDER_ROW_ID,
+ sourceHandle: previewIsLast ? 'output' : SEQUENTIAL_BAR_HANDLE_IDS.source,
+ targetHandle: SEQUENTIAL_BAR_HANDLE_IDS.target,
+ type: SEQ_CONNECTOR_EDGE_TYPE,
+ data: previewIsLast ? syntheticPreviewData() : syntheticData(),
+ ...(previewIsLast
+ ? {
+ style: {
+ opacity: 0.8,
+ stroke: 'var(--canvas-selection-indicator)',
+ strokeWidth: 2,
+ },
+ }
+ : {}),
+ });
+ }
+
+ return edges;
+}
+
+/**
+ * Stamps each numbered row's clone with `node.ariaLabel` (xyflow renders this
+ * as `aria-label` on the node's DOM wrapper): "Step {n} of {total}: {label}"
+ * (D8). `total` counts every numbered row regardless of visibility, so it
+ * never shifts when a container collapses (D7's stable-numbering guarantee
+ * extends to the announced total). The label is resolved through the node
+ * type registry the same way `BaseNode` resolves its printed label
+ * (`resolveDisplay(manifest.display, {display: node.data.display})`), so the
+ * announced name matches what's on the bar; a registry-less host (or an
+ * unrecognized type) still gets a readable fallback ("Unknown Node") instead
+ * of a blank label. Synthetic rows (start bar, placeholder) are left
+ * untouched -- they already carry their own visible, non-templated text.
+ *
+ * This only runs inside the {@link useSequentialGraph} hook (which has React
+ * context for the registry + lingui), not {@link deriveSequentialGraph} (the
+ * pure function exercised directly by tests without mounting either provider).
+ */
+function stampStepAriaLabels(
+ nodes: Node[],
+ projection: SequenceProjection,
+ registry: NodeTypeRegistry | null,
+ translate: TranslateFn
+): Node[] {
+ const stepNumberByRowId = new Map();
+ let total = 0;
+ for (const row of projection.rows) {
+ if (row.stepNumber === undefined) continue;
+ stepNumberByRowId.set(row.nodeId, row.stepNumber);
+ total += 1;
+ }
+ if (total === 0) return nodes;
+
+ return nodes.map((node) => {
+ const stepNumber = stepNumberByRowId.get(node.id);
+ if (stepNumber === undefined) return node;
+
+ const manifest = registry?.getManifest(node.type ?? '');
+ const display = resolveDisplay(manifest?.display, {
+ display: (node.data as { display?: InstanceDisplayConfig } | undefined)?.display,
+ });
+
+ const ariaLabel = translate({
+ id: 'sequential-canvas.step.aria-label',
+ message: 'Step {stepNumber} of {total}: {label}',
+ values: { stepNumber, total, label: display.label },
+ });
+
+ return { ...node, ariaLabel };
+ });
+}
+
+function shallowRecordEqual(
+ previous: Record,
+ next: Record
+): boolean {
+ const previousKeys = Object.keys(previous);
+ const nextKeys = Object.keys(next);
+ if (previousKeys.length !== nextKeys.length) return false;
+ for (const key of previousKeys) {
+ if (!Object.hasOwn(next, key)) return false;
+ if (!Object.is(previous[key], next[key])) return false;
+ }
+ return true;
+}
+
+function shallowNodeEqualExceptPositionAndData(previous: Node, next: Node): boolean {
+ const previousRecord = previous as unknown as Record;
+ const nextRecord = next as unknown as Record;
+ const previousKeys = Object.keys(previousRecord);
+ const nextKeys = Object.keys(nextRecord);
+ if (previousKeys.length !== nextKeys.length) return false;
+ for (const key of previousKeys) {
+ if (!Object.hasOwn(nextRecord, key)) return false;
+ if (key === 'position' || key === 'data') continue;
+ if (!Object.is(previousRecord[key], nextRecord[key])) return false;
+ }
+ return true;
+}
+
+/**
+ * Reuses the prior object for every derived row whose rendered inputs did not
+ * change. A canonical selection update replaces the canonical nodes array, but
+ * normally changes only the previously-selected and newly-selected nodes.
+ * Keeping every other derived node reference stable lets xyflow preserve its
+ * internal node entries instead of reconciling the entire graph.
+ */
+function reuseUnchangedSequentialNodes(previous: Node[], next: Node[]): Node[] {
+ const previousById = new Map(previous.map((node) => [node.id, node]));
+ return next.map((node) => {
+ const prior = previousById.get(node.id);
+ if (!prior) return node;
+
+ const priorPosition = prior.position;
+ const nextPosition = node.position;
+ const priorData = prior.data as Record;
+ const nextData = node.data as Record;
+ const hasGeneratedData =
+ node.type === SEQ_START_NODE_TYPE || node.type === SEQ_PLACEHOLDER_NODE_TYPE;
+ const dataEqual =
+ Object.is(prior.data, node.data) ||
+ (hasGeneratedData && shallowRecordEqual(priorData, nextData));
+
+ return priorPosition.x === nextPosition.x &&
+ priorPosition.y === nextPosition.y &&
+ dataEqual &&
+ shallowNodeEqualExceptPositionAndData(prior, node)
+ ? prior
+ : node;
+ });
+}
+
+/**
+ * React hook wrapping {@link deriveSequentialGraph} with the D12 memoization:
+ * projection + layout recompute only when the STRUCTURAL fingerprint changes, so
+ * a rename keystroke (data-only) reuses them. The clone nodes recompute when the
+ * canonical `nodes` reference changes (to reflect new `data`), always reading the
+ * fingerprint-memoized layout positions; the connector edges are reference-stable
+ * across data-only changes (they depend only on projection + layout), satisfying
+ * the WS3 edge-data stability contract.
+ */
+export function useSequentialGraph(
+ args: UseSequentialGraphArgs
+): SequentialGraph {
+ const {
+ nodes,
+ edges,
+ view,
+ collapsedStepIds,
+ onAddTrigger,
+ onPlaceholderAdd,
+ isSequenceEdge,
+ isSequenceNode,
+ isStartNode,
+ isContainerNode,
+ resolveBranchLabel,
+ getBranchHandles,
+ onLaneAdd,
+ insertSlot,
+ layoutOptions,
+ } = args;
+
+ const { _ } = useSafeLingui();
+ const registry = useOptionalNodeTypeRegistry();
+ const previousSeqNodesRef = useRef([]);
+
+ const collapsed = collapsedStepIds ?? EMPTY_COLLAPSED;
+ const fingerprint = useMemo(
+ () => sequenceFingerprint(nodes, edges, collapsed),
+ [nodes, edges, collapsed]
+ );
+ // These fingerprints only guard the sequential projection memo below, which
+ // short-circuits to null in flow view; skip the full-graph passes there.
+ const inSequentialView = view === 'sequential';
+ const labelFingerprint = useMemo(
+ () =>
+ inSequentialView
+ ? edges
+ .map((edge) => {
+ const handleId = edge.sourceHandle ?? DEFAULT_SOURCE_HANDLE_ID;
+ const resolved = resolveBranchLabel?.(edge.source, handleId);
+ const fallback = (edge.data as { label?: string | null } | undefined)?.label;
+ // NUL separates id from label so neither can forge a collision.
+ return `${edge.id} ${resolved ?? fallback ?? ''}`;
+ })
+ .sort()
+ .join('|')
+ : '',
+ [inSequentialView, edges, resolveBranchLabel]
+ );
+ const edgeInclusionFingerprint = useMemo(
+ () => (inSequentialView ? predicateFingerprint(edges, isSequenceEdge, 1) : ''),
+ [inSequentialView, edges, isSequenceEdge]
+ );
+ const nodeInclusionFingerprint = useMemo(
+ () => (inSequentialView ? predicateFingerprint(nodes, isSequenceNode, 1) : ''),
+ [inSequentialView, nodes, isSequenceNode]
+ );
+ const startNodeFingerprint = useMemo(
+ () => (inSequentialView ? predicateFingerprint(nodes, isStartNode, 0) : ''),
+ [inSequentialView, nodes, isStartNode]
+ );
+ const containerNodeFingerprint = useMemo(
+ () => (inSequentialView ? predicateFingerprint(nodes, isContainerNode, 0) : ''),
+ [inSequentialView, nodes, isContainerNode]
+ );
+ // Which nodes are parents (branch-lane handles + their labels), so the
+ // projection recomputes when a node gains/loses lanes or a lane relabels.
+ const branchHandlesFingerprint = useMemo(
+ () =>
+ inSequentialView && getBranchHandles
+ ? nodes
+ .map(
+ (node) =>
+ `${node.id}:${getBranchHandles(node)
+ .map((h) => `${h.id}=${h.label}`)
+ .join(',')}`
+ )
+ .join('|')
+ : '',
+ [inSequentialView, nodes, getBranchHandles]
+ );
+
+ // Recompute the projection only on a structural change (the fingerprint, D12)
+ // or when the projection predicates change identity; a data-only edit (e.g. a
+ // rename keystroke) produces the same fingerprint string and reuses the cached
+ // projection. Keying on `fingerprint` instead of the raw nodes/edges arrays is
+ // the whole point, so the exhaustive-deps rule is intentionally overridden.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the structural fingerprint by design (D12).
+ const projection = useMemo(() => {
+ if (view !== 'sequential') return null;
+ const sequenceNodes = isSequenceNode ? nodes.filter(isSequenceNode) : nodes;
+ const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id));
+ const sequenceEdges = edges.filter(
+ (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target)
+ );
+ return projectSequence(sequenceNodes, sequenceEdges, {
+ collapsedStepIds: collapsed,
+ isSequenceEdge: isSequenceEdge as ((edge: Edge) => boolean) | undefined,
+ isStartNode: isStartNode as ((node: Node) => boolean) | undefined,
+ isContainerNode: isContainerNode as ((node: Node) => boolean) | undefined,
+ resolveBranchLabel,
+ getBranchHandles: getBranchHandles as
+ | ((node: Node) => { id: string; label: string }[])
+ | undefined,
+ });
+ }, [
+ fingerprint,
+ labelFingerprint,
+ edgeInclusionFingerprint,
+ nodeInclusionFingerprint,
+ startNodeFingerprint,
+ containerNodeFingerprint,
+ branchHandlesFingerprint,
+ view,
+ ]);
+
+ // Layout is a cheap pure geometry pass, kept separate from the projection memo
+ // so it can re-run for the insert-preview gap WITHOUT re-projecting: while a
+ // slot's Add Node panel is open, lay out a preview-augmented projection so the
+ // gap opens one row, the preview bar lands in its slot, and every connector
+ // re-routes from the shifted positions.
+ const renderedProjection = useMemo(() => {
+ if (!projection) return null;
+ return insertSlot ? projectionWithPreviewRow(projection, insertSlot) : projection;
+ }, [projection, insertSlot]);
+
+ const layout = useMemo(
+ () => (renderedProjection ? layoutSequence(renderedProjection, layoutOptions) : null),
+ [renderedProjection, layoutOptions]
+ );
+
+ const seqNodes = useMemo(() => {
+ if (!projection || !layout) return null;
+ const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
+ const built = buildSequentialNodes(
+ projection,
+ layout,
+ nodesById,
+ {
+ onAddTrigger,
+ onPlaceholderAdd,
+ onLaneAdd,
+ },
+ layoutOptions
+ );
+ const stamped = stampStepAriaLabels(built, projection, registry, _);
+ const stable = reuseUnchangedSequentialNodes(previousSeqNodesRef.current, stamped);
+ previousSeqNodesRef.current = stable;
+ return stable;
+ }, [
+ projection,
+ layout,
+ nodes,
+ onAddTrigger,
+ onPlaceholderAdd,
+ onLaneAdd,
+ layoutOptions,
+ registry,
+ _,
+ ]);
+
+ const seqEdges = useMemo(() => {
+ if (!renderedProjection || !layout) return null;
+ return buildSequentialEdges(renderedProjection, layout);
+ }, [renderedProjection, layout]);
+
+ if (view !== 'sequential' || !projection || !seqNodes || !seqEdges) {
+ return { nodes, edges, projection: null, layout: null };
+ }
+
+ return {
+ nodes: seqNodes as unknown as N[],
+ edges: seqEdges as unknown as E[],
+ projection,
+ layout,
+ };
+}
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts
new file mode 100644
index 000000000..f5b8397fd
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.test.ts
@@ -0,0 +1,387 @@
+import { renderHook } from '@testing-library/react';
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ getAdjacentRowId,
+ isTypingTarget,
+ type SequentialKeyboardRow,
+ toggleCollapsedStepIds,
+ useSequentialKeyboard,
+} from './useSequentialKeyboard';
+
+function makeRow(overrides: Partial = {}): SequentialKeyboardRow {
+ return { nodeId: 'a', collapsible: false, collapsed: false, ...overrides };
+}
+
+function makeKeyEvent(
+ key: string,
+ target: EventTarget = document.body,
+ altKey = false
+): ReactKeyboardEvent {
+ return {
+ key,
+ target,
+ altKey,
+ preventDefault: vi.fn(),
+ } as unknown as ReactKeyboardEvent;
+}
+
+describe('isTypingTarget', () => {
+ it('is false for a plain div', () => {
+ expect(isTypingTarget(document.createElement('div'))).toBe(false);
+ });
+
+ it.each(['INPUT', 'TEXTAREA', 'SELECT'])('is true for a %s element', (tag) => {
+ expect(isTypingTarget(document.createElement(tag))).toBe(true);
+ });
+
+ it('is true for a contenteditable element', () => {
+ const div = document.createElement('div');
+ Object.defineProperty(div, 'isContentEditable', { value: true });
+ expect(isTypingTarget(div)).toBe(true);
+ });
+
+ it('is false for null', () => {
+ expect(isTypingTarget(null)).toBe(false);
+ });
+});
+
+describe('getAdjacentRowId', () => {
+ const rows = [{ nodeId: 'a' }, { nodeId: 'b' }, { nodeId: 'c' }];
+
+ it('returns the next row after the selected one', () => {
+ expect(getAdjacentRowId(rows, 'a', 'next')).toBe('b');
+ });
+
+ it('returns the previous row before the selected one', () => {
+ expect(getAdjacentRowId(rows, 'c', 'prev')).toBe('b');
+ });
+
+ it('returns undefined past the last row (no wraparound)', () => {
+ expect(getAdjacentRowId(rows, 'c', 'next')).toBeUndefined();
+ });
+
+ it('returns undefined before the first row (no wraparound)', () => {
+ expect(getAdjacentRowId(rows, 'a', 'prev')).toBeUndefined();
+ });
+
+ it('jumps to the first row on next when nothing is selected', () => {
+ expect(getAdjacentRowId(rows, undefined, 'next')).toBe('a');
+ });
+
+ it('jumps to the last row on prev when nothing is selected', () => {
+ expect(getAdjacentRowId(rows, undefined, 'prev')).toBe('c');
+ });
+
+ it('returns undefined for an empty row list', () => {
+ expect(getAdjacentRowId([], undefined, 'next')).toBeUndefined();
+ });
+});
+
+describe('toggleCollapsedStepIds', () => {
+ it('adds the id when collapsing', () => {
+ expect(toggleCollapsedStepIds(new Set(['x']), 'a', true).sort()).toEqual(['a', 'x']);
+ });
+
+ it('removes the id when expanding', () => {
+ expect(toggleCollapsedStepIds(new Set(['a', 'x']), 'a', false)).toEqual(['x']);
+ });
+});
+
+describe('useSequentialKeyboard', () => {
+ it('ignores keys entirely while the event target is a typing surface', () => {
+ const onSelectNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowDown', document.createElement('input')));
+ expect(onSelectNode).not.toHaveBeenCalled();
+ });
+
+ it('moves selection to the next row on ArrowDown and prevents default', () => {
+ const onSelectNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ })
+ );
+
+ const event = makeKeyEvent('ArrowDown');
+ result.current.onKeyDown(event);
+ expect(onSelectNode).toHaveBeenCalledWith('b');
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('moves selection to the previous row on ArrowUp', () => {
+ const onSelectNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })],
+ selectedNodeId: 'b',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowUp'));
+ expect(onSelectNode).toHaveBeenCalledWith('a');
+ });
+
+ it('does not call onSelectNode at a boundary with no adjacent row', () => {
+ const onSelectNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowDown'));
+ expect(onSelectNode).not.toHaveBeenCalled();
+ });
+
+ it('collapses the selected row on ArrowLeft when it is collapsible and expanded', () => {
+ const onCollapsedStepIdsChange = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: false })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onCollapsedStepIdsChange,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowLeft'));
+ expect(onCollapsedStepIdsChange).toHaveBeenCalledWith(['a']);
+ });
+
+ it('does nothing on ArrowLeft when the selected row is not collapsible', () => {
+ const onCollapsedStepIdsChange = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a', collapsible: false })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onCollapsedStepIdsChange,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowLeft'));
+ expect(onCollapsedStepIdsChange).not.toHaveBeenCalled();
+ });
+
+ it('does nothing on ArrowLeft when the selected row is already collapsed', () => {
+ const onCollapsedStepIdsChange = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: true })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(['a']),
+ onSelectNode: vi.fn(),
+ onCollapsedStepIdsChange,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowLeft'));
+ expect(onCollapsedStepIdsChange).not.toHaveBeenCalled();
+ });
+
+ it('expands the selected row on ArrowRight when it is collapsible and collapsed', () => {
+ const onCollapsedStepIdsChange = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a', collapsible: true, collapsed: true })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(['a']),
+ onSelectNode: vi.fn(),
+ onCollapsedStepIdsChange,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowRight'));
+ expect(onCollapsedStepIdsChange).toHaveBeenCalledWith([]);
+ });
+
+ it('fires onPrimaryAction with the selected node id on Enter when supplied', () => {
+ const onPrimaryAction = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onPrimaryAction,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('Enter'));
+ expect(onPrimaryAction).toHaveBeenCalledWith('a');
+ });
+
+ it('is a no-op on Enter when no onPrimaryAction is supplied', () => {
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ })
+ );
+
+ const event = makeKeyEvent('Enter');
+ expect(() => result.current.onKeyDown(event)).not.toThrow();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ 'Delete',
+ 'Backspace',
+ ])('semantically deletes the selected row on %s in design mode', (key) => {
+ const onDeleteNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onDeleteNode,
+ isDesignMode: true,
+ })
+ );
+
+ const event = makeKeyEvent(key);
+ result.current.onKeyDown(event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(onDeleteNode).toHaveBeenCalledWith('a');
+ });
+
+ it('does not delete outside design mode', () => {
+ const onDeleteNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onDeleteNode,
+ isDesignMode: false,
+ })
+ );
+
+ const event = makeKeyEvent('Delete');
+ result.current.onKeyDown(event);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(onDeleteNode).not.toHaveBeenCalled();
+ });
+
+ describe('Alt+Arrow tree move', () => {
+ it.each([
+ ['ArrowUp', 'up'],
+ ['ArrowDown', 'down'],
+ ['ArrowLeft', 'outdent'],
+ ['ArrowRight', 'indent'],
+ ] as const)('calls onMoveNode with direction %s -> %s in design mode', (key, direction) => {
+ const onMoveNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onMoveNode,
+ isDesignMode: true,
+ })
+ );
+
+ const event = makeKeyEvent(key, document.body, true);
+ result.current.onKeyDown(event);
+ expect(onMoveNode).toHaveBeenCalledWith('a', direction);
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('does not call onMoveNode outside design mode, and does not fall through to plain navigation', () => {
+ const onMoveNode = vi.fn();
+ const onSelectNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ onMoveNode,
+ isDesignMode: false,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowDown', document.body, true));
+ expect(onMoveNode).not.toHaveBeenCalled();
+ expect(onSelectNode).not.toHaveBeenCalled();
+ });
+
+ it('does not call onMoveNode with no row selected', () => {
+ const onMoveNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: undefined,
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onMoveNode,
+ isDesignMode: true,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowUp', document.body, true));
+ expect(onMoveNode).not.toHaveBeenCalled();
+ });
+
+ it('plain (non-Alt) ArrowUp/Down/Left/Right still navigate/collapse exactly as before', () => {
+ const onSelectNode = vi.fn();
+ const onMoveNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' }), makeRow({ nodeId: 'b' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode,
+ onMoveNode,
+ isDesignMode: true,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowDown'));
+ expect(onSelectNode).toHaveBeenCalledWith('b');
+ expect(onMoveNode).not.toHaveBeenCalled();
+ });
+
+ it('ignores Alt+Arrow entirely while the event target is a typing surface', () => {
+ const onMoveNode = vi.fn();
+ const { result } = renderHook(() =>
+ useSequentialKeyboard({
+ rows: [makeRow({ nodeId: 'a' })],
+ selectedNodeId: 'a',
+ collapsedStepIds: new Set(),
+ onSelectNode: vi.fn(),
+ onMoveNode,
+ isDesignMode: true,
+ })
+ );
+
+ result.current.onKeyDown(makeKeyEvent('ArrowUp', document.createElement('textarea'), true));
+ expect(onMoveNode).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts
new file mode 100644
index 000000000..c8ebc4dc7
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/SequentialCanvas/useSequentialKeyboard.ts
@@ -0,0 +1,236 @@
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
+import { useCallback } from 'react';
+import type { SequenceRow } from '../../utils/sequential/sequential.types';
+
+/** The subset of a row's fields the keyboard hook needs to navigate/collapse it. */
+export interface SequentialKeyboardRow
+ extends Pick {}
+
+export interface UseSequentialKeyboardArgs {
+ /**
+ * Visible, numbered rows in row order -- the same array `SequentialGutter`
+ * renders from (see `SequentialCanvas.tsx`), so ArrowUp/Down walk the exact
+ * order screen readers traverse the DOM in (D8: `onlyRenderVisibleElements`
+ * off means DOM order === row order).
+ */
+ rows: readonly SequentialKeyboardRow[];
+ /** The single selected row's node id, if any (single-select contract). */
+ selectedNodeId?: string;
+ collapsedStepIds: ReadonlySet;
+ /**
+ * Moves single selection to a row's node id. The canvas assembly wires this
+ * through the same `onNodesChange` path a mouse click already uses (a
+ * `select` NodeChange per touched node), so keyboard and pointer selection
+ * are indistinguishable to the consumer's canonical state.
+ */
+ onSelectNode: (nodeId: string) => void;
+ onCollapsedStepIdsChange?: (ids: string[]) => void;
+ /**
+ * Enter's primary action, if the host supplies one. `SequentialCanvas` does
+ * not wire this today: a bar's toolbar actions are resolved per-node deep
+ * inside `BaseNode`/`BaseNodeBar` via `resolveToolbar` + that node's own
+ * manifest/status context (see `BaseNodeBar.tsx`'s `menuItems` memo), and are
+ * never surfaced centrally to the canvas assembly. Reusing "the first
+ * resolved action" here would require either lifting toolbar resolution out
+ * of BaseNode (a BaseNode-owning change) or adding an event-bus side channel
+ * the bars call into on mount -- both bigger than this component's scope.
+ * Enter is therefore a documented no-op until a host supplies `onPrimaryAction`.
+ */
+ onPrimaryAction?: (nodeId: string) => void;
+ /**
+ * Alt+Arrow explorer-like tree move: Alt+ArrowUp = move
+ * up, Alt+ArrowDown = move down, Alt+ArrowLeft = outdent, Alt+ArrowRight =
+ * indent, applied to the selected row. Shares the SAME commit path the
+ * kebab items use (see `useSequentialMoveMenuItems` / `commitMove` in
+ * `SequentialCanvas.tsx`), so keyboard and kebab can never disagree.
+ * Gated on `isDesignMode` (a topology mutation, D4) in addition to the
+ * typing-target guard every other key already has; a plain Arrow key
+ * without Alt is untouched by this and keeps navigating/collapsing exactly
+ * as before.
+ */
+ onMoveNode?: (nodeId: string, direction: 'up' | 'down' | 'indent' | 'outdent') => void;
+ /** Semantically removes the selected step and heals its graph seam. */
+ onDeleteNode?: (nodeId: string) => void;
+ isDesignMode?: boolean;
+}
+
+export interface UseSequentialKeyboardResult {
+ /** Attach to a wrapper div around `BaseCanvas` (see `SequentialCanvas.tsx`). */
+ onKeyDown: (event: ReactKeyboardEvent) => void;
+}
+
+const TYPING_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
+
+/**
+ * True when the event's target is an editable surface: an inline-rename
+ * `
),
},
+ {
+ name: 'Sequential Canvas',
+ description:
+ 'Vertical, n8n-style projection of a flow graph with a flow/sequential view toggle',
+ storyPath: 'components-sequentialcanvas-sequentialcanvas--wireframe',
+ category: Category.Canvas,
+ preview: (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ ),
+ },
// ── Nodes ────────────────────────────────────────────────────────────────
{
@@ -524,10 +542,10 @@ const components: ComponentInfo[] = [
category: Category.Utilities,
preview: (
-
-
-
-
+
+
+
+
),
},
diff --git a/packages/apollo-react/src/canvas/components/index.ts b/packages/apollo-react/src/canvas/components/index.ts
index 3cf5e8f0f..ca768e215 100644
--- a/packages/apollo-react/src/canvas/components/index.ts
+++ b/packages/apollo-react/src/canvas/components/index.ts
@@ -22,6 +22,7 @@ export * from './NodeIOView';
export * from './NodePropertiesPanel';
export * from './NodePropertyPanel';
export * from './ProbeCard';
+export * from './SequentialCanvas';
export * from './StageNode';
export * from './StickyNoteNode';
export * from './shared';
diff --git a/packages/apollo-react/src/canvas/constants.ts b/packages/apollo-react/src/canvas/constants.ts
index 236e23b4d..2616d6fac 100644
--- a/packages/apollo-react/src/canvas/constants.ts
+++ b/packages/apollo-react/src/canvas/constants.ts
@@ -1,6 +1,7 @@
export const PREVIEW_NODE_ID = 'preview-node-id';
export const PREVIEW_EDGE_ID = 'preview-edge-id';
export const DEFAULT_SOURCE_HANDLE_ID = 'output';
+export const DEFAULT_TARGET_HANDLE_ID = 'input';
export const DEFAULT_NODE_SIZE = 96; // px
export const GRID_SPACING = 16;
@@ -65,3 +66,61 @@ export const NODE_TEXT_BOTTOM_OFFSET_WITH_HANDLES = -40;
export const NODE_BADGE_SIZE = 20;
export const NODE_BADGE_INSET_SQUARE = 6;
export const NODE_BADGE_INSET_CIRCLE = 12;
+
+/**
+ * Sequential Canvas View geometry (px). Bars are not draggable in v1, so
+ * grid-snap alignment of the height is a non-issue -- `SEQ_BAR_HEIGHT` was
+ * tightened off the 16px grid per visual-polish feedback; every other
+ * value here still sits on the 16px grid so bars never kink against snapped
+ * edges.
+ */
+/** Bar width. 900 is off the 16px grid, so 896 (56 * 16) is used instead. */
+export const SEQ_BAR_WIDTH = 896;
+/** Bar height. Tightened from the original 72 (4.5 * 16) to a more compact row per visual-polish feedback. */
+export const SEQ_BAR_HEIGHT = 56;
+/**
+ * Vertical gap between bar rows (4 * 16). Widened from the original 48
+ * (3 * 16) per visual-polish feedback, leaving room for branch section
+ * headers and connector insert affordances between adjacent bars.
+ */
+export const SEQ_ROW_GAP = 64;
+/** Horizontal offset per indent depth level: `x = depth * SEQ_INDENT_PX` (4 * 16). */
+export const SEQ_INDENT_PX = 64;
+/**
+ * Corner radius (px) for sequential connector elbows, giving the branch and
+ * merge-back connectors a pronounced smooth-step curve rather than the subtler
+ * default edge radius. `createRoundedPath` clamps this to half the shorter
+ * adjacent segment, so tight jogs round gracefully.
+ */
+export const SEQ_EDGE_CORNER_RADIUS = 24;
+/**
+ * Fixed horizontal offset (px) of a bar's invisible top/bottom connector
+ * handles from the bar's OWN left edge, rather than its horizontal center
+ * (per visual-polish feedback: connectors must drop from the bottom-left
+ * region, matching the n8n/Zapier-style wireframe). Because child bars are
+ * already indented `depth * SEQ_INDENT_PX`, anchoring every bar's handles at
+ * the SAME offset from its own left edge automatically produces depth-aligned
+ * vertical spines with no per-depth math needed at the edge/connector layer.
+ * Kept comfortably below `SEQ_INDENT_PX` (64) so adjacent depths' spines never
+ * overlap or cross.
+ */
+export const SEQ_HANDLE_LEFT_OFFSET = 28;
+
+/**
+ * Synthetic node `type` keys for the Sequential Canvas view. Real step clones
+ * KEEP their manifest type (they resolve the real BaseNode via the sequential
+ * nodeTypes map); only the injected start bar and terminal placeholder use these
+ * synthetic types. Defined here as the single source of truth shared by the node
+ * components (nodes/index.ts) and the insert pipeline (edges/sequentialInsert.ts)
+ * so the two never drift.
+ */
+export const SEQ_START_NODE_TYPE = 'sequential-start';
+export const SEQ_PLACEHOLDER_NODE_TYPE = 'sequential-placeholder';
+
+/**
+ * Default product node `type` treated as the sequence start/trigger when the
+ * host doesn't supply its own `isStartNode` predicate. Shared by
+ * `projectSequence`'s default and `SequentialCanvas`'s registry-aware predicate
+ * so the two can't drift.
+ */
+export const DEFAULT_TRIGGER_NODE_TYPE = 'uipath.first-run';
diff --git a/packages/apollo-react/src/canvas/hooks/usePreviewNode.test.ts b/packages/apollo-react/src/canvas/hooks/usePreviewNode.test.ts
new file mode 100644
index 000000000..24828bdf1
--- /dev/null
+++ b/packages/apollo-react/src/canvas/hooks/usePreviewNode.test.ts
@@ -0,0 +1,80 @@
+import type { Edge } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { PREVIEW_NODE_ID } from '../constants';
+import { NodeTypeRegistry } from '../core/NodeTypeRegistry';
+import { resolvePreviewExistingHandle, resolvePreviewExistingHandleId } from './usePreviewNode';
+
+describe('resolvePreviewExistingHandleId', () => {
+ it('uses canonical metadata when a derived preview edge renders on a view-only source handle', () => {
+ const edge: Edge = {
+ id: 'incoming-preview',
+ source: 'existing',
+ sourceHandle: 'seq-source',
+ target: PREVIEW_NODE_ID,
+ targetHandle: 'input',
+ data: { previewConnectionHandleId: 'success' },
+ };
+
+ expect(resolvePreviewExistingHandleId(edge)).toBe('success');
+ });
+
+ it('uses canonical metadata when a derived preview edge renders on a view-only target handle', () => {
+ const edge: Edge = {
+ id: 'outgoing-preview',
+ source: PREVIEW_NODE_ID,
+ sourceHandle: 'output',
+ target: 'existing',
+ targetHandle: 'seq-target',
+ data: { previewConnectionHandleId: 'input' },
+ };
+
+ expect(resolvePreviewExistingHandleId(edge)).toBe('input');
+ });
+
+ it('keeps the normal rendered-handle fallback for non-derived preview edges', () => {
+ expect(
+ resolvePreviewExistingHandleId({
+ id: 'ordinary-preview',
+ source: 'existing',
+ sourceHandle: 'custom-output',
+ target: PREVIEW_NODE_ID,
+ })
+ ).toBe('custom-output');
+ });
+});
+
+describe('resolvePreviewExistingHandle', () => {
+ const registry = new NodeTypeRegistry();
+ registry.registerNodeManifests([
+ {
+ nodeType: 'script',
+ version: '1',
+ category: 'actions',
+ display: { label: 'Script' },
+ tags: [],
+ handleConfiguration: [
+ {
+ position: 'right',
+ handles: [
+ { id: 'success', type: 'source', handleType: 'output' },
+ { id: 'error', type: 'source', handleType: 'output' },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ it('maps generic output to the manifest default when no literal output handle exists', () => {
+ expect(resolvePreviewExistingHandle(registry, 'script', 'output', 'source')).toMatchObject({
+ handleId: 'success',
+ manifest: { id: 'success' },
+ });
+ });
+
+ it('does not hide an unknown explicit handle behind the default', () => {
+ expect(resolvePreviewExistingHandle(registry, 'script', 'unknown', 'source')).toEqual({
+ handleId: 'unknown',
+ manifest: undefined,
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts b/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
index f9fd279cb..a4fdb7c28 100644
--- a/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
+++ b/packages/apollo-react/src/canvas/hooks/usePreviewNode.ts
@@ -8,10 +8,16 @@ import {
import { useMemo } from 'react';
import { shallow } from 'zustand/shallow';
import { PREVIEW_NODE_ID } from '../constants';
-import { useOptionalNodeTypeRegistry } from '../core';
+import { type NodeTypeRegistry, useOptionalNodeTypeRegistry } from '../core';
import type { HandleManifest, NodeManifest } from '../schema/node-definition';
import { isPreviewEdge } from '../utils/createPreviewNode';
+type PreviewConnectionEdgeData = {
+ ignorePreviewConnection?: boolean;
+ /** Canonical existing-endpoint handle when the rendered edge uses a view-only anchor. */
+ previewConnectionHandleId?: string;
+};
+
/**
* Information about an existing node connected to the preview node.
*
@@ -42,13 +48,23 @@ const previewNodeSelectedSelector = (state: ReactFlowState) => {
// Selector to track edges connected to preview node
// Returns minimal edge data to avoid unnecessary re-renders
const edgesConnectedToPreviewSelector = (state: ReactFlowState): Edge[] => {
- return state.edges.filter(isPreviewEdge).map((edge) => ({
- id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- }));
+ return state.edges
+ .filter(
+ (edge) =>
+ isPreviewEdge(edge) &&
+ (edge.data as PreviewConnectionEdgeData | undefined)?.ignorePreviewConnection !== true
+ )
+ .map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ data: {
+ previewConnectionHandleId: (edge.data as PreviewConnectionEdgeData | undefined)
+ ?.previewConnectionHandleId,
+ },
+ }));
};
interface UsePreviewNodeResult {
@@ -61,6 +77,48 @@ interface UsePreviewNodeResult {
previewNodeConnectionInfo: Array
| null;
}
+/**
+ * Resolves the existing node's canonical handle for Add Node validation.
+ * Some derived views render preview edges on view-only handles, so they carry
+ * the manifest handle separately in edge data.
+ */
+export function resolvePreviewExistingHandleId(previewEdge: Edge): string {
+ const sourceIsPreviewNode = previewEdge.source === PREVIEW_NODE_ID;
+ const canonicalHandleId = (previewEdge.data as PreviewConnectionEdgeData | undefined)
+ ?.previewConnectionHandleId;
+ return sourceIsPreviewNode
+ ? (canonicalHandleId ?? previewEdge.targetHandle ?? 'input')
+ : (canonicalHandleId ?? previewEdge.sourceHandle ?? 'output');
+}
+
+/**
+ * Resolves a requested existing-node handle against its manifest. Generic
+ * `input`/`output` ids are aliases for the manifest default when that literal
+ * id is absent; any other unknown explicit id remains invalid.
+ */
+export function resolvePreviewExistingHandle(
+ registry: Pick | null,
+ nodeType: string | undefined,
+ requestedHandleId: string,
+ handleType: 'source' | 'target'
+): { handleId: string; manifest: HandleManifest | undefined } {
+ const manifest = nodeType ? registry?.getManifest(nodeType) : undefined;
+ const exactHandle = manifest?.handleConfiguration
+ .flatMap((group) => group.handles)
+ .find((handle) => handle.id === requestedHandleId);
+ if (exactHandle) return { handleId: requestedHandleId, manifest: exactHandle };
+
+ const genericAlias = handleType === 'source' ? 'output' : 'input';
+ const defaultHandle =
+ requestedHandleId === genericAlias && nodeType
+ ? registry?.getDefaultHandle(nodeType, handleType)
+ : undefined;
+ return {
+ handleId: defaultHandle?.id ?? requestedHandleId,
+ manifest: defaultHandle,
+ };
+}
+
/**
* Hook to track the selected preview node and its connection information.
*
@@ -105,29 +163,32 @@ export const usePreviewNode = (): UsePreviewNodeResult => {
: undefined;
// Determine which handle on the existing node is involved.
- const existingHandleId = sourceIsPreviewNode
- ? previewEdge.targetHandle || 'input'
- : previewEdge.sourceHandle || 'output';
+ const existingHandleId = resolvePreviewExistingHandleId(previewEdge);
// Pre-compute the handle manifest here so consumers don't need to look it up repeatedly.
- const existingHandleManifest = existingNodeManifest?.handleConfiguration
+ // Repeating handles retain their existing prefix matching; ordinary handles
+ // additionally allow generic input/output to resolve to the manifest default.
+ const repeatedHandleManifest = existingNodeManifest?.handleConfiguration
.flatMap((hg) => hg.handles)
- .find((h) => {
- if (h.id === existingHandleId) return true;
-
- const repeatHandleIdBase = h.repeat && h.id.split('{')[0];
- if (repeatHandleIdBase) {
- return existingHandleId.startsWith(repeatHandleIdBase);
- }
- return false;
+ .find((handle) => {
+ const repeatHandleIdBase = handle.repeat && handle.id.split('{')[0];
+ return repeatHandleIdBase ? existingHandleId.startsWith(repeatHandleIdBase) : false;
});
+ const resolvedHandle = repeatedHandleManifest
+ ? { handleId: existingHandleId, manifest: repeatedHandleManifest }
+ : resolvePreviewExistingHandle(
+ registry,
+ existingNodeType,
+ existingHandleId,
+ sourceIsPreviewNode ? 'target' : 'source'
+ );
return {
addNewNodeAsSource: sourceIsPreviewNode,
existingNodeId,
- existingHandleId,
+ existingHandleId: resolvedHandle.handleId,
existingNodeManifest,
- existingHandleManifest,
+ existingHandleManifest: resolvedHandle.manifest,
previewEdgeId: previewEdge.id,
};
});
diff --git a/packages/apollo-react/src/canvas/locales/de.json b/packages/apollo-react/src/canvas/locales/de.json
index 957ad3308..a8bdde814 100644
--- a/packages/apollo-react/src/canvas/locales/de.json
+++ b/packages/apollo-react/src/canvas/locales/de.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Als Voreinstellung speichern",
"canvas.property_trigger.apply_preset_with_label": "{label} anwenden",
"canvas.property_trigger.rename_preset_with_label": "{label} umbenennen",
- "canvas.property_trigger.delete_preset_with_label": "{label} löschen"
+ "canvas.property_trigger.delete_preset_with_label": "{label} löschen",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/en.json b/packages/apollo-react/src/canvas/locales/en.json
index 13d78139e..0412618e9 100644
--- a/packages/apollo-react/src/canvas/locales/en.json
+++ b/packages/apollo-react/src/canvas/locales/en.json
@@ -147,5 +147,29 @@
"canvas.node_mode_select.simulated_description": "Generate a response dynamically using an LLM",
"canvas.node_mode_select.simulated_label": "Simulated",
"canvas.node_mode_select.static_description": "Always return a value you define",
- "canvas.node_mode_select.static_label": "Static mock"
+ "canvas.node_mode_select.static_label": "Static mock",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.new-step": "New step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/es-MX.json b/packages/apollo-react/src/canvas/locales/es-MX.json
index fb359e845..670b5272c 100644
--- a/packages/apollo-react/src/canvas/locales/es-MX.json
+++ b/packages/apollo-react/src/canvas/locales/es-MX.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Guardar como ajuste preestablecido",
"canvas.property_trigger.apply_preset_with_label": "Aplicar {label}",
"canvas.property_trigger.rename_preset_with_label": "Cambiar el nombre de {label}",
- "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/es.json b/packages/apollo-react/src/canvas/locales/es.json
index 997cf6419..61f43e396 100644
--- a/packages/apollo-react/src/canvas/locales/es.json
+++ b/packages/apollo-react/src/canvas/locales/es.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Guardar como ajuste preestablecido",
"canvas.property_trigger.apply_preset_with_label": "Aplicar {label}",
"canvas.property_trigger.rename_preset_with_label": "Cambiar el nombre de {label}",
- "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/fr.json b/packages/apollo-react/src/canvas/locales/fr.json
index 344ff17a9..d8ae1d86d 100644
--- a/packages/apollo-react/src/canvas/locales/fr.json
+++ b/packages/apollo-react/src/canvas/locales/fr.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Enregistrer cet agencement",
"canvas.property_trigger.apply_preset_with_label": "Appliquer {label}",
"canvas.property_trigger.rename_preset_with_label": "Renommer {label}",
- "canvas.property_trigger.delete_preset_with_label": "Supprimer {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Supprimer {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/ja.json b/packages/apollo-react/src/canvas/locales/ja.json
index b212bd657..e4b86a012 100644
--- a/packages/apollo-react/src/canvas/locales/ja.json
+++ b/packages/apollo-react/src/canvas/locales/ja.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "プリセットとして保存",
"canvas.property_trigger.apply_preset_with_label": "{label} を適用",
"canvas.property_trigger.rename_preset_with_label": "{label} の名前を変更",
- "canvas.property_trigger.delete_preset_with_label": "{label} を削除"
+ "canvas.property_trigger.delete_preset_with_label": "{label} を削除",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/ko.json b/packages/apollo-react/src/canvas/locales/ko.json
index e3cd8e5aa..2cfce97d9 100644
--- a/packages/apollo-react/src/canvas/locales/ko.json
+++ b/packages/apollo-react/src/canvas/locales/ko.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "사전 설정으로 저장",
"canvas.property_trigger.apply_preset_with_label": "{label} 적용",
"canvas.property_trigger.rename_preset_with_label": "{label} 이름 바꾸기",
- "canvas.property_trigger.delete_preset_with_label": "{label}삭제"
+ "canvas.property_trigger.delete_preset_with_label": "{label}삭제",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/pt-BR.json b/packages/apollo-react/src/canvas/locales/pt-BR.json
index 55a975c4a..91bddd7ab 100644
--- a/packages/apollo-react/src/canvas/locales/pt-BR.json
+++ b/packages/apollo-react/src/canvas/locales/pt-BR.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Salvar como predefinição",
"canvas.property_trigger.apply_preset_with_label": "Aplicar {label}",
"canvas.property_trigger.rename_preset_with_label": "Renomear {label}",
- "canvas.property_trigger.delete_preset_with_label": "Excluir {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Excluir {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/pt.json b/packages/apollo-react/src/canvas/locales/pt.json
index d2f9d5133..13c8b54cf 100644
--- a/packages/apollo-react/src/canvas/locales/pt.json
+++ b/packages/apollo-react/src/canvas/locales/pt.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Guardar como predefinição",
"canvas.property_trigger.apply_preset_with_label": "Aplicar {label}",
"canvas.property_trigger.rename_preset_with_label": "Mudar o nome de {label}",
- "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Eliminar {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/ro.json b/packages/apollo-react/src/canvas/locales/ro.json
index 023b85fa6..42ac5b2fd 100644
--- a/packages/apollo-react/src/canvas/locales/ro.json
+++ b/packages/apollo-react/src/canvas/locales/ro.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Salvați ca presetare",
"canvas.property_trigger.apply_preset_with_label": "Aplicați {label}",
"canvas.property_trigger.rename_preset_with_label": "Redenumește {label}",
- "canvas.property_trigger.delete_preset_with_label": "Șterge {label}"
+ "canvas.property_trigger.delete_preset_with_label": "Șterge {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/ru.json b/packages/apollo-react/src/canvas/locales/ru.json
index 378816c94..eb86fcc70 100644
--- a/packages/apollo-react/src/canvas/locales/ru.json
+++ b/packages/apollo-react/src/canvas/locales/ru.json
@@ -4,5 +4,28 @@
"sticky-note.formatting.strikethrough": "",
"sticky-note.formatting.bullet-list": "",
"sticky-note.formatting.numbered-list": "",
- "sticky-note.formatting.toolbar": ""
+ "sticky-note.formatting.toolbar": "",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/tr.json b/packages/apollo-react/src/canvas/locales/tr.json
index d8b77e0ec..b42a38175 100644
--- a/packages/apollo-react/src/canvas/locales/tr.json
+++ b/packages/apollo-react/src/canvas/locales/tr.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "Hazır ayar olarak kaydet",
"canvas.property_trigger.apply_preset_with_label": "{label} öğesini uygula",
"canvas.property_trigger.rename_preset_with_label": "{label} kategorisini yeniden adlandır",
- "canvas.property_trigger.delete_preset_with_label": "{label} öğesini sil"
+ "canvas.property_trigger.delete_preset_with_label": "{label} öğesini sil",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/zh-CN.json b/packages/apollo-react/src/canvas/locales/zh-CN.json
index 7e9d5bbc1..50b73ee06 100644
--- a/packages/apollo-react/src/canvas/locales/zh-CN.json
+++ b/packages/apollo-react/src/canvas/locales/zh-CN.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "另存为预设",
"canvas.property_trigger.apply_preset_with_label": "应用 {label}",
"canvas.property_trigger.rename_preset_with_label": "重命名 {label}",
- "canvas.property_trigger.delete_preset_with_label": "删除{label}"
+ "canvas.property_trigger.delete_preset_with_label": "删除{label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/locales/zh-TW.json b/packages/apollo-react/src/canvas/locales/zh-TW.json
index 31e917153..518f9a135 100644
--- a/packages/apollo-react/src/canvas/locales/zh-TW.json
+++ b/packages/apollo-react/src/canvas/locales/zh-TW.json
@@ -63,5 +63,28 @@
"canvas.property_trigger.save_as_preset": "儲存為預設",
"canvas.property_trigger.apply_preset_with_label": "套用 {label}",
"canvas.property_trigger.rename_preset_with_label": "重新命名 {label}",
- "canvas.property_trigger.delete_preset_with_label": "刪除 {label}"
+ "canvas.property_trigger.delete_preset_with_label": "刪除 {label}",
+ "sequential-canvas.start.title": "Workflow start",
+ "sequential-canvas.start.add-trigger": "Add trigger",
+ "sequential-canvas.step.aria-label": "Step {stepNumber} of {total}: {label}",
+ "sequential-canvas.steps.aria-label": "Workflow steps",
+ "sequential-canvas.placeholder.add": "Add step",
+ "sequential-canvas.insert-step": "Insert step",
+ "sequential-canvas.continues-at-step": "Continues at step {stepNumber}",
+ "sequential-canvas.gutter.expand-step": "Expand step {stepNumber}",
+ "sequential-canvas.gutter.collapse-step": "Collapse step {stepNumber}",
+ "sequential-canvas.view.flow": "Flow",
+ "sequential-canvas.view.sequential": "Sequential",
+ "sequential-canvas.view.label": "Canvas view",
+ "sequential-canvas.action-needed": "Action needed",
+ "sequential-canvas.more-options": "More options",
+ "sequential-canvas.move.up": "Move up",
+ "sequential-canvas.move.down": "Move down",
+ "sequential-canvas.move.indent": "Move into previous step",
+ "sequential-canvas.move.outdent": "Move out",
+ "sequential-canvas.diagnostics.multi-root": "Multiple workflow roots",
+ "sequential-canvas.diagnostics.unstructured-merge": "Unstructured merge",
+ "sequential-canvas.diagnostics.cycle": "Cycle",
+ "sequential-canvas.diagnostics.orphan": "Disconnected step",
+ "sequential-canvas.diagnostics.summary": "{count, plural, one {# projection notice} other {# projection notices}}"
}
diff --git a/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts b/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
index a9e9b0977..f79b947f2 100644
--- a/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
+++ b/packages/apollo-react/src/canvas/storybook-utils/manifests/node-definitions.ts
@@ -13,7 +13,19 @@ export const allNodeManifests: NodeManifest[] = [
icon: 'plus',
shape: 'circle',
},
- handleConfiguration: [],
+ handleConfiguration: [
+ {
+ position: 'right',
+ handles: [
+ {
+ id: 'output',
+ type: 'source',
+ handleType: 'output',
+ },
+ ],
+ visible: true,
+ },
+ ],
},
// Blank Node
@@ -313,14 +325,12 @@ export const allNodeManifests: NodeManifest[] = [
display: {
label: 'While',
icon: 'repeat',
+ shape: 'container',
},
handleConfiguration: [
{
position: 'left',
- handles: [
- { id: 'input', type: 'target', handleType: 'input' },
- { id: 'loopBack', type: 'target', handleType: 'input' },
- ],
+ handles: [{ id: 'input', type: 'target', handleType: 'input' }],
},
{
position: 'right',
@@ -331,6 +341,12 @@ export const allNodeManifests: NodeManifest[] = [
type: 'source',
handleType: 'output',
},
+ ],
+ },
+ {
+ position: 'left',
+ boundary: 'inner',
+ handles: [
{
id: 'body',
label: 'Body',
@@ -339,6 +355,18 @@ export const allNodeManifests: NodeManifest[] = [
},
],
},
+ {
+ position: 'right',
+ boundary: 'inner',
+ handles: [
+ {
+ id: 'loopBack',
+ label: 'Loop back',
+ type: 'target',
+ handleType: 'input',
+ },
+ ],
+ },
],
},
diff --git a/packages/apollo-react/src/canvas/storybook-utils/sequential/SequentialCanvasStoryHarness.tsx b/packages/apollo-react/src/canvas/storybook-utils/sequential/SequentialCanvasStoryHarness.tsx
new file mode 100644
index 000000000..123c1f97d
--- /dev/null
+++ b/packages/apollo-react/src/canvas/storybook-utils/sequential/SequentialCanvasStoryHarness.tsx
@@ -0,0 +1,173 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { applyEdgeChanges, applyNodeChanges } from '@uipath/apollo-react/canvas/xyflow/react';
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { prepareCanvasViewTransition } from '../../components/SequentialCanvas/prepareCanvasViewTransition';
+import { SequentialCanvas } from '../../components/SequentialCanvas/SequentialCanvas';
+import { ViewSwitcher } from '../../components/SequentialCanvas/ViewSwitcher';
+import { NodeRegistryProvider, useOptionalNodeTypeRegistry } from '../../core';
+import type { NodeManifest } from '../../schema';
+import { isContainerNodeManifest } from '../../utils';
+import type {
+ CanvasView,
+ LayoutSequenceOptions,
+ SequentialCompatibilityReport,
+} from '../../utils/sequential';
+import { defaultWorkflowManifest } from '../manifests';
+
+/**
+ * Story-only helper for the Sequential Canvas view stories. It renders
+ * a controlled sequential graph with realistic manifest-resolved node
+ * visuals. It can optionally expose the real flow/sequential transition over the
+ * same controlled canonical graph.
+ *
+ * `extraManifests` lets one story register additional node types (e.g. the
+ * wireframe fixture's "HTTP Request" / "Send Message to User", which have no
+ * other Storybook manifest) without touching the shared
+ * defaultWorkflowManifest every other canvas story relies on.
+ */
+export interface SequentialCanvasStoryHarnessProps {
+ initialNodes: Node[];
+ initialEdges: Edge[];
+ extraManifests?: NodeManifest[];
+ mode?: 'design' | 'view' | 'readonly';
+ sequenceLayoutOptions?: LayoutSequenceOptions;
+ initialView?: CanvasView;
+ showViewSwitcher?: boolean;
+ /** Wired to the "Workflow start" bar's "Add trigger" button (SequentialStartNode). */
+ onAddTrigger?: () => void;
+}
+
+export function SequentialCanvasStoryHarness({
+ initialNodes,
+ initialEdges,
+ extraManifests,
+ mode = 'design',
+ sequenceLayoutOptions,
+ initialView = 'sequential',
+ showViewSwitcher = false,
+ onAddTrigger,
+}: SequentialCanvasStoryHarnessProps) {
+ const manifest = useMemo(
+ () =>
+ extraManifests?.length
+ ? {
+ ...defaultWorkflowManifest,
+ nodes: [...defaultWorkflowManifest.nodes, ...extraManifests],
+ }
+ : defaultWorkflowManifest,
+ [extraManifests]
+ );
+
+ return (
+
+
+
+ );
+}
+
+function SequentialCanvasStoryHarnessInner({
+ initialNodes,
+ initialEdges,
+ mode,
+ sequenceLayoutOptions,
+ initialView,
+ showViewSwitcher,
+ onAddTrigger,
+}: Required<
+ Pick<
+ SequentialCanvasStoryHarnessProps,
+ 'initialNodes' | 'initialEdges' | 'initialView' | 'mode' | 'showViewSwitcher'
+ >
+> &
+ Pick) {
+ const [nodes, setNodes] = useState(initialNodes);
+ const [edges, setEdges] = useState(initialEdges);
+ const registry = useOptionalNodeTypeRegistry();
+ const isContainerNode = useCallback(
+ (node: Node) =>
+ isContainerNodeManifest(node.type ? registry?.getManifest(node.type) : undefined),
+ [registry]
+ );
+ const [view, setView] = useState(initialView);
+ const [compatibility, setCompatibility] = useState(
+ () =>
+ initialView === 'sequential'
+ ? prepareCanvasViewTransition('sequential', initialNodes, initialEdges, {
+ sequential: { isContainerNode },
+ }).sequentialCompatibility
+ : undefined
+ );
+ const nodesRef = useRef(nodes);
+ nodesRef.current = nodes;
+ const edgesRef = useRef(edges);
+ edgesRef.current = edges;
+ // Collapse is view-local UI (D6), not canonical state, so it lives here
+ // rather than alongside nodes/edges. Wiring it through makes the gutter's
+ // chevrons and ArrowLeft/Right actually collapse/expand in stories,
+ // instead of rendering inert affordances.
+ const [collapsedStepIds, setCollapsedStepIds] = useState([]);
+ const onNodesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setNodes((current) => {
+ const next = applyNodeChanges(changes, current);
+ nodesRef.current = next;
+ return next;
+ }),
+ []
+ );
+ const onEdgesChange = useCallback(
+ (changes: Parameters>[0]) =>
+ setEdges((current) => applyEdgeChanges(changes, current)),
+ []
+ );
+ const onViewChange = useCallback(
+ (nextView: CanvasView) => {
+ const transition = prepareCanvasViewTransition(nextView, nodesRef.current, edgesRef.current, {
+ flowLayout: { isContainerNode },
+ sequential: { isContainerNode },
+ });
+ nodesRef.current = transition.nodes;
+ setNodes(transition.nodes);
+ setCompatibility(transition.sequentialCompatibility);
+ setView(nextView);
+ },
+ [isContainerNode]
+ );
+ const effectiveMode =
+ view === 'sequential' && compatibility && !compatibility.editable ? 'readonly' : mode;
+
+ return (
+
+ {showViewSwitcher && (
+
+
+
+ )}
+ {view === 'sequential' && compatibility && compatibility.level !== 'exact' && (
+
+ Sequential view is read-only: {compatibility.issues.map((issue) => issue.code).join(', ')}
+
+ )}
+
+
+ );
+}
diff --git a/packages/apollo-react/src/canvas/storybook-utils/sequential/index.ts b/packages/apollo-react/src/canvas/storybook-utils/sequential/index.ts
new file mode 100644
index 000000000..973ba76f2
--- /dev/null
+++ b/packages/apollo-react/src/canvas/storybook-utils/sequential/index.ts
@@ -0,0 +1,7 @@
+// Local barrel for the Sequential Canvas story helpers. Not re-exported
+// from storybook-utils/index.ts; SequentialCanvas.stories.tsx imports directly
+// from here so this stays scoped to sequential-canvas stories only.
+
+export type { SequentialCanvasStoryHarnessProps } from './SequentialCanvasStoryHarness';
+export { SequentialCanvasStoryHarness } from './SequentialCanvasStoryHarness';
+export { sequentialWireframeManifests } from './wireframeManifests';
diff --git a/packages/apollo-react/src/canvas/storybook-utils/sequential/wireframeManifests.ts b/packages/apollo-react/src/canvas/storybook-utils/sequential/wireframeManifests.ts
new file mode 100644
index 000000000..9f0b4cdac
--- /dev/null
+++ b/packages/apollo-react/src/canvas/storybook-utils/sequential/wireframeManifests.ts
@@ -0,0 +1,72 @@
+import type { NodeManifest } from '../../schema';
+
+/**
+ * Manifests for the two `utils/sequential/fixtures.ts` `makeWireframeFixture`
+ * node types that have no other Storybook registration: "HTTP Request" and
+ * "Send Message to User". `uipath.script`, `uipath.control-flow.foreach`, and
+ * `uipath.control-flow.decision` (the fixture's other three types) already
+ * exist in storybook-utils/manifests/node-definitions.ts.
+ *
+ * BaseNode renders `MissingManifestNode` whenever no manifest resolves for a
+ * node's `type` (BaseNode.tsx, the `if (!manifest)` branch, checked before the
+ * card/bar split), so these two are required for the wireframe story to show
+ * realistic icons rather than the missing-manifest placeholder. Kept as an
+ * additive array passed to SequentialCanvasStoryHarness's `extraManifests`
+ * (never merged into the shared `defaultWorkflowManifest`) so no other canvas
+ * story is affected.
+ *
+ * Both manifests also carry `display.iconBackground` so BaseNodeBar's colored
+ * left accent strip (gated on that field, see BaseNodeBar.tsx) is exercised in
+ * the Wireframe story: the Send Message node uses Slack's brand purple (the
+ * connector it represents), and the HTTP Request node uses a distinct token
+ * hue so the two branded/integration bars are visually differentiated.
+ */
+export const sequentialWireframeManifests: NodeManifest[] = [
+ {
+ nodeType: 'uipath.http-request',
+ version: '1',
+ category: 'connector',
+ tags: ['http', 'request', 'api', 'connector'],
+ sortOrder: 5,
+ description: 'Call an external HTTP API and capture the response',
+ display: {
+ label: 'HTTP Request',
+ icon: 'globe',
+ iconBackground: 'var(--color-blue-500)',
+ },
+ handleConfiguration: [
+ {
+ position: 'left',
+ handles: [{ id: 'input', type: 'target', handleType: 'input' }],
+ },
+ {
+ position: 'right',
+ handles: [{ id: 'output', type: 'source', handleType: 'output' }],
+ },
+ ],
+ },
+ {
+ nodeType: 'uipath.send-message',
+ version: '1',
+ category: 'connector',
+ tags: ['send', 'message', 'notify', 'connector'],
+ sortOrder: 6,
+ description: 'Send a message to a user through a configured channel',
+ display: {
+ label: 'Send Message to User',
+ icon: 'send',
+ // Slack brand purple: this node represents a Slack-style message connector.
+ iconBackground: '#611f69',
+ },
+ handleConfiguration: [
+ {
+ position: 'left',
+ handles: [{ id: 'input', type: 'target', handleType: 'input' }],
+ },
+ {
+ position: 'right',
+ handles: [{ id: 'output', type: 'source', handleType: 'output' }],
+ },
+ ],
+ },
+];
diff --git a/packages/apollo-react/src/canvas/utils/index.ts b/packages/apollo-react/src/canvas/utils/index.ts
index b565838e7..fff255a22 100644
--- a/packages/apollo-react/src/canvas/utils/index.ts
+++ b/packages/apollo-react/src/canvas/utils/index.ts
@@ -18,5 +18,7 @@ export * from './nodePropsEqual';
export * from './NodeUtils';
export * from './props-helpers';
export * from './resource-operations';
+export * from './sequential';
export * from './Storage';
export * from './StyleUtil';
+export * from './workflow-layout';
diff --git a/packages/apollo-react/src/canvas/utils/sequential/compatibility.test.ts b/packages/apollo-react/src/canvas/utils/sequential/compatibility.test.ts
new file mode 100644
index 000000000..0e0c5b472
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/compatibility.test.ts
@@ -0,0 +1,131 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { analyzeSequentialCompatibility } from './compatibility';
+import {
+ makeCycleFixture,
+ makeEmptyBranchFixture,
+ makeMultiRootFixture,
+ makeOrphanFixture,
+ makeUnstructuredMergeFixture,
+ makeWireframeFixture,
+} from './fixtures';
+
+describe('analyzeSequentialCompatibility', () => {
+ it('marks structured sequences, branches, merges, and containers as exactly editable', () => {
+ for (const fixture of [makeWireframeFixture(), makeEmptyBranchFixture()]) {
+ const report = analyzeSequentialCompatibility(fixture.nodes, fixture.edges);
+ expect(report.level).toBe('exact');
+ expect(report.editable).toBe(true);
+ expect(report.issues).toEqual([]);
+ }
+ });
+
+ it('marks multiple roots, gotos, cycles, and orphans as degraded and read-only', () => {
+ const cases = [
+ [makeMultiRootFixture(), 'multiple-roots'],
+ [makeUnstructuredMergeFixture(), 'unstructured-merge'],
+ [makeCycleFixture(), 'cycle'],
+ [makeOrphanFixture(), 'orphan'],
+ ] as const;
+
+ for (const [fixture, expectedIssue] of cases) {
+ const report = analyzeSequentialCompatibility(fixture.nodes, fixture.edges);
+ expect(report.level).toBe('degraded');
+ expect(report.editable).toBe(false);
+ expect(report.issues.some((issue) => issue.code === expectedIssue)).toBe(true);
+ }
+ });
+
+ it('counts independently triggered terminal nodes as multiple roots', () => {
+ const trigger: Node = {
+ id: 'trigger',
+ type: 'uipath.trigger',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const first: Node = {
+ id: 'first',
+ type: 'step',
+ position: { x: 100, y: 0 },
+ data: {},
+ };
+ const second: Node = {
+ id: 'second',
+ type: 'step',
+ position: { x: 100, y: 100 },
+ data: {},
+ };
+ const edges: Edge[] = [
+ { id: 'trigger-first', source: trigger.id, target: first.id },
+ { id: 'trigger-second', source: trigger.id, target: second.id },
+ ];
+
+ const report = analyzeSequentialCompatibility([trigger, first, second], edges, {
+ isStartNode: (node) => node.id === trigger.id,
+ });
+
+ expect(report.level).toBe('degraded');
+ expect(report.editable).toBe(false);
+ expect(report.issues).toContainEqual({
+ code: 'multiple-roots',
+ severity: 'warning',
+ nodeIds: ['first', 'second'],
+ scopeId: undefined,
+ });
+ });
+
+ it('preserves presentation-only nodes and edges without degrading control flow', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const sticky: Node = {
+ id: 'note',
+ type: 'sticky-note',
+ position: { x: 100, y: 100 },
+ data: { text: 'Remember this' },
+ };
+ const annotationEdge: Edge = {
+ id: 'annotation-edge',
+ source: 'note',
+ target: 'http',
+ };
+ const report = analyzeSequentialCompatibility([...nodes, sticky], [...edges, annotationEdge], {
+ isSequenceNode: (node) => node.type !== 'sticky-note',
+ });
+
+ expect(report.level).toBe('exact');
+ expect(report.preservedOnlyNodeIds).toEqual(['note']);
+ expect(report.preservedOnlyEdgeIds).toContain('annotation-edge');
+ expect(report.projectedNodeIds).not.toContain('note');
+ });
+
+ it('rejects malformed containment in the unified graph', () => {
+ const missingParent: Node = {
+ id: 'child',
+ type: 'step',
+ parentId: 'missing',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const a: Node = {
+ id: 'a',
+ type: 'container',
+ parentId: 'b',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const b: Node = {
+ id: 'b',
+ type: 'container',
+ parentId: 'a',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+
+ const report = analyzeSequentialCompatibility([missingParent, a, b], []);
+
+ expect(report.level).toBe('unsupported');
+ expect(report.editable).toBe(false);
+ expect(report.issues.map((issue) => issue.code)).toEqual(
+ expect.arrayContaining(['missing-parent', 'parent-cycle'])
+ );
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/compatibility.ts b/packages/apollo-react/src/canvas/utils/sequential/compatibility.ts
new file mode 100644
index 000000000..96a2275b6
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/compatibility.ts
@@ -0,0 +1,214 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_TRIGGER_NODE_TYPE } from '../../constants';
+import { defaultIsSequenceEdge, LOOP_BACK_HANDLE_ID } from './graph-helpers';
+import { projectSequence } from './projectSequence';
+import type { ProjectSequenceOptions, SequenceProjection } from './sequential.types';
+
+export type SequentialCompatibilityLevel = 'exact' | 'degraded' | 'unsupported';
+
+export type SequentialCompatibilityIssueCode =
+ | 'cycle'
+ | 'duplicate-node-id'
+ | 'missing-parent'
+ | 'multiple-roots'
+ | 'orphan'
+ | 'parent-cycle'
+ | 'unstructured-merge';
+
+export interface SequentialCompatibilityIssue {
+ code: SequentialCompatibilityIssueCode;
+ severity: 'warning' | 'error';
+ /** Canonical nodes involved in the issue, kept deterministic for UI and tests. */
+ nodeIds: string[];
+ /** Canonical or derived connector ids involved in the issue. */
+ edgeIds?: string[];
+ /** Containment scope for a multiple-root issue; undefined means the root canvas. */
+ scopeId?: string;
+}
+
+export interface SequentialCompatibilityReport {
+ level: SequentialCompatibilityLevel;
+ /** Sequential mutations are safe only when the structure has an exact projection. */
+ editable: boolean;
+ issues: SequentialCompatibilityIssue[];
+ /** Canonical nodes represented as numbered sequential rows. */
+ projectedNodeIds: string[];
+ /** Presentation-only nodes intentionally retained in canonical state but omitted here. */
+ preservedOnlyNodeIds: string[];
+ /** Non-control-flow edges retained in canonical state but omitted from the projection. */
+ preservedOnlyEdgeIds: string[];
+ projection: SequenceProjection;
+}
+
+export interface AnalyzeSequentialCompatibilityOptions extends ProjectSequenceOptions {
+ /** Excludes presentation-only concepts such as sticky notes from sequential rows. */
+ isSequenceNode?: (node: Node) => boolean;
+}
+
+/**
+ * Describes how faithfully one canonical node/edge graph can be presented and
+ * edited as a sequential workflow. This is analysis, not conversion: excluded
+ * presentation concepts remain in canonical state and toggling views never
+ * rewrites topology.
+ */
+export function analyzeSequentialCompatibility(
+ nodes: Node[],
+ edges: Edge[],
+ options: AnalyzeSequentialCompatibilityOptions = {}
+): SequentialCompatibilityReport {
+ const isSequenceNode = options.isSequenceNode ?? (() => true);
+ const isStartNode =
+ options.isStartNode ?? ((node: Node) => node.type === DEFAULT_TRIGGER_NODE_TYPE);
+ const isSequenceEdge = options.isSequenceEdge ?? defaultIsSequenceEdge;
+
+ const preservedOnlyNodes = nodes.filter((node) => !isSequenceNode(node));
+ const preservedOnlyNodeIds = new Set(preservedOnlyNodes.map((node) => node.id));
+ const semanticNodes = nodes.filter(isSequenceNode);
+ const semanticNodeIds = new Set(semanticNodes.map((node) => node.id));
+ const semanticEdges = edges.filter(
+ (edge) =>
+ semanticNodeIds.has(edge.source) && semanticNodeIds.has(edge.target) && isSequenceEdge(edge)
+ );
+ const semanticEdgeIds = new Set(semanticEdges.map((edge) => edge.id));
+ const projection = projectSequence(semanticNodes, semanticEdges, options);
+
+ const issues: SequentialCompatibilityIssue[] = [];
+ const nodeById = new Map();
+ for (const node of semanticNodes) {
+ if (nodeById.has(node.id)) {
+ issues.push({ code: 'duplicate-node-id', severity: 'error', nodeIds: [node.id] });
+ } else {
+ nodeById.set(node.id, node);
+ }
+ }
+
+ for (const node of semanticNodes) {
+ if (node.parentId && !nodeById.has(node.parentId)) {
+ issues.push({
+ code: 'missing-parent',
+ severity: 'error',
+ nodeIds: [node.id, node.parentId],
+ });
+ }
+ }
+
+ const reportedParentCycles = new Set();
+ for (const node of semanticNodes) {
+ const path: string[] = [];
+ const pathIndex = new Map();
+ let current: Node | undefined = node;
+ while (current) {
+ const seenAt = pathIndex.get(current.id);
+ if (seenAt !== undefined) {
+ const cycle = path.slice(seenAt).sort();
+ const key = cycle.join('|');
+ if (!reportedParentCycles.has(key)) {
+ reportedParentCycles.add(key);
+ issues.push({ code: 'parent-cycle', severity: 'error', nodeIds: cycle });
+ }
+ break;
+ }
+ pathIndex.set(current.id, path.length);
+ path.push(current.id);
+ current = current.parentId ? nodeById.get(current.parentId) : undefined;
+ }
+ }
+
+ const nonStartNodes = semanticNodes.filter((node) => !isStartNode(node));
+ const nonStartNodeIds = new Set(nonStartNodes.map((node) => node.id));
+ const forwardEdges = semanticEdges.filter(
+ (edge) =>
+ nonStartNodeIds.has(edge.source) &&
+ nonStartNodeIds.has(edge.target) &&
+ edge.targetHandle !== LOOP_BACK_HANDLE_ID
+ );
+
+ const scopes = new Map();
+ for (const node of nonStartNodes) {
+ const scoped = scopes.get(node.parentId);
+ if (scoped) scoped.push(node);
+ else scopes.set(node.parentId, [node]);
+ }
+ for (const [scopeId, scopedNodes] of scopes) {
+ const scopedNodeIds = new Set(scopedNodes.map((node) => node.id));
+ const incoming = new Set();
+ for (const edge of forwardEdges) {
+ if (!scopedNodeIds.has(edge.source) || !scopedNodeIds.has(edge.target)) continue;
+ incoming.add(edge.target);
+ }
+ const roots = scopedNodes
+ .filter((node) => !incoming.has(node.id))
+ .map((node) => node.id)
+ .sort();
+ if (roots.length > 1) {
+ issues.push({
+ code: 'multiple-roots',
+ severity: 'warning',
+ nodeIds: roots,
+ scopeId,
+ });
+ }
+ }
+
+ const adjacency = new Map();
+ for (const edge of forwardEdges) {
+ const targets = adjacency.get(edge.source);
+ if (targets) targets.push(edge.target);
+ else adjacency.set(edge.source, [edge.target]);
+ }
+ const hasPath = (start: string, target: string): boolean => {
+ if (start === target) return true;
+ const visited = new Set();
+ const pending = [start];
+ while (pending.length > 0) {
+ const current = pending.pop()!;
+ if (visited.has(current)) continue;
+ visited.add(current);
+ for (const next of adjacency.get(current) ?? []) {
+ if (next === target) return true;
+ if (!visited.has(next)) pending.push(next);
+ }
+ }
+ return false;
+ };
+
+ for (const connector of projection.connectors) {
+ if (connector.kind !== 'goto') continue;
+ const closesCycle = hasPath(connector.targetRowId, connector.sourceRowId);
+ issues.push({
+ code: closesCycle ? 'cycle' : 'unstructured-merge',
+ severity: 'warning',
+ nodeIds: [connector.sourceRowId, connector.targetRowId],
+ edgeIds: [connector.id],
+ });
+ }
+
+ const orphanIds = projection.rows
+ .filter((row) => row.orphan)
+ .map((row) => row.nodeId)
+ .sort();
+ if (orphanIds.length > 0) {
+ issues.push({ code: 'orphan', severity: 'warning', nodeIds: orphanIds });
+ }
+
+ const level: SequentialCompatibilityLevel = issues.some((issue) => issue.severity === 'error')
+ ? 'unsupported'
+ : issues.length > 0
+ ? 'degraded'
+ : 'exact';
+
+ return {
+ level,
+ editable: level === 'exact',
+ issues,
+ projectedNodeIds: projection.rows
+ .filter((row) => !row.lanePlaceholder)
+ .map((row) => row.nodeId),
+ preservedOnlyNodeIds: [...preservedOnlyNodeIds].sort(),
+ preservedOnlyEdgeIds: edges
+ .filter((edge) => !semanticEdgeIds.has(edge.id))
+ .map((edge) => edge.id)
+ .sort(),
+ projection,
+ };
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/fingerprint.test.ts b/packages/apollo-react/src/canvas/utils/sequential/fingerprint.test.ts
new file mode 100644
index 000000000..cb261bfef
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/fingerprint.test.ts
@@ -0,0 +1,134 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import { sequenceFingerprint } from './fingerprint';
+import { makeMultiRootFixture, makeWireframeFixture, WIREFRAME_NODE_IDS } from './fixtures';
+import { SEQ_CONTINUATION_EDGE_KEY } from './graph-helpers';
+
+const EMPTY = new Set();
+
+function plainNode(id: string, x: number, y: number): Node {
+ return { id, type: 'uipath.script', position: { x, y }, data: {} };
+}
+
+describe('sequenceFingerprint', () => {
+ const base = makeWireframeFixture();
+ const baseline = sequenceFingerprint(base.nodes, base.edges, EMPTY);
+
+ it('is invariant under data-only changes (rename keystrokes)', () => {
+ const renamed = base.nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.http
+ ? { ...node, data: { display: { label: 'Renamed HTTP call' } } }
+ : node
+ );
+ expect(sequenceFingerprint(renamed, base.edges, EMPTY)).toBe(baseline);
+ });
+
+ it('is invariant under position-only changes that preserve sibling order', () => {
+ const moved = base.nodes.map((node) => ({
+ ...node,
+ position: { x: node.position.x + 100, y: node.position.y + 100 },
+ }));
+ expect(sequenceFingerprint(moved, base.edges, EMPTY)).toBe(baseline);
+ });
+
+ it('changes when position changes reorder siblings', () => {
+ const moved = base.nodes.map((node) => ({ ...node, position: { x: 999, y: 999 } }));
+ expect(sequenceFingerprint(moved, base.edges, EMPTY)).not.toBe(baseline);
+ });
+
+ it('is invariant under input array reordering', () => {
+ const shuffledNodes = [...base.nodes].reverse();
+ const shuffledEdges = [...base.edges].reverse();
+ expect(sequenceFingerprint(shuffledNodes, shuffledEdges, EMPTY)).toBe(baseline);
+ });
+
+ it('changes when the collapsed set changes', () => {
+ expect(
+ sequenceFingerprint(base.nodes, base.edges, new Set([WIREFRAME_NODE_IDS.forEach]))
+ ).not.toBe(baseline);
+ });
+
+ it('changes when an edge endpoint changes', () => {
+ const rewired = base.edges.map((edge) =>
+ edge.id === 'e-http-js' ? { ...edge, target: WIREFRAME_NODE_IDS.forEach } : edge
+ );
+ expect(sequenceFingerprint(base.nodes, rewired, EMPTY)).not.toBe(baseline);
+ });
+
+ it('changes when an edge becomes an explicit sequential continuation', () => {
+ const marked = base.edges.map((edge) =>
+ edge.id === 'e-http-js'
+ ? { ...edge, data: { ...edge.data, [SEQ_CONTINUATION_EDGE_KEY]: true } }
+ : edge
+ );
+ expect(sequenceFingerprint(base.nodes, marked, EMPTY)).not.toBe(baseline);
+ });
+
+ it('changes when a node type changes', () => {
+ const retyped = base.nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.ifNode ? { ...node, type: 'uipath.control-flow.switch' } : node
+ );
+ expect(sequenceFingerprint(retyped, base.edges, EMPTY)).not.toBe(baseline);
+ });
+
+ it('changes when container nesting (parentId) changes', () => {
+ const reparented = base.nodes.map((node) =>
+ node.id === WIREFRAME_NODE_IDS.sendMessage
+ ? { ...node, parentId: WIREFRAME_NODE_IDS.forEach }
+ : node
+ );
+ expect(sequenceFingerprint(reparented, base.edges, EMPTY)).not.toBe(baseline);
+ });
+
+ it('changes when a node is added or removed', () => {
+ const fewer = base.nodes.slice(0, -1);
+ expect(sequenceFingerprint(fewer, base.edges, EMPTY)).not.toBe(baseline);
+ });
+
+ describe('delimiter escaping (F6)', () => {
+ it('does not collide when a field value contains the raw delimiter characters', () => {
+ const graphA: Edge[] = [];
+ const nodesA = [plainNode('a:b', 0, 0)].map((n) => ({ ...n, type: 'c', parentId: 'd' }));
+ const nodesB = [plainNode('a', 0, 0)].map((n) => ({ ...n, type: 'b:c', parentId: 'd' }));
+ expect(sequenceFingerprint(nodesA, graphA, EMPTY)).not.toBe(
+ sequenceFingerprint(nodesB, graphA, EMPTY)
+ );
+ });
+ });
+
+ describe('root ordering (F6)', () => {
+ it('changes when reordering multiple roots by flow-view y, even though node/edge identity is unchanged', () => {
+ const { nodes, edges } = makeMultiRootFixture();
+ const reordered = nodes.map((n) =>
+ n.id === 'a'
+ ? { ...n, position: { x: 0, y: 900 } }
+ : n.id === 'c'
+ ? { ...n, position: { x: 0, y: -900 } }
+ : n
+ );
+ expect(sequenceFingerprint(reordered, edges, EMPTY)).not.toBe(
+ sequenceFingerprint(nodes, edges, EMPTY)
+ );
+ });
+
+ it('stays invariant under a uniform position shift that does not change relative order', () => {
+ const { nodes, edges } = makeMultiRootFixture();
+ const shifted = nodes.map((n) => ({
+ ...n,
+ position: { x: n.position.x + 10, y: n.position.y + 10 },
+ }));
+ expect(sequenceFingerprint(shifted, edges, EMPTY)).toBe(
+ sequenceFingerprint(nodes, edges, EMPTY)
+ );
+ });
+ });
+
+ describe('edge identity (F6)', () => {
+ it('changes when an edge is replaced by a different id with identical endpoints', () => {
+ const rewired = base.edges.map((e) =>
+ e.id === 'e-http-js' ? { ...e, id: 'renamed-id' } : e
+ );
+ expect(sequenceFingerprint(base.nodes, rewired, EMPTY)).not.toBe(baseline);
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/fingerprint.ts b/packages/apollo-react/src/canvas/utils/sequential/fingerprint.ts
new file mode 100644
index 000000000..09bb2cf6e
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/fingerprint.ts
@@ -0,0 +1,76 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import {
+ buildGraphIndex,
+ defaultIsSequenceEdge,
+ isSequentialContinuationEdge,
+ sortByFlow,
+} from './graph-helpers';
+
+/**
+ * Structural fingerprint used to memoize the projection and layout (D12): node
+ * ids + node types + parentId (structural nesting) + edge ids/endpoints/handles
+ * + the sequential-continuation edge bit + each scope's resolved entry order
+ * + the collapsed set. Deliberately excludes all other `node.data`/`edge.data`
+ * and raw `node.position`, so data-only changes (rename keystrokes touch
+ * node.data per keypress) reuse the cached projection and positions unchanged.
+ *
+ * Every channel value is JSON-encoded before joining so a value containing a
+ * separator character (`:`, `|`, `#`) can never be confused with a field
+ * boundary (e.g. `{id:'a:b', type:'c'}` must not fingerprint identically to
+ * `{id:'a', type:'b:c'}`).
+ *
+ * Row order is not purely a function of node/edge identity: each scope's entry
+ * nodes (top-level roots, and any container body with more than one
+ * independent entry) are ordered by flow-view position (`entriesForScope`,
+ * D9), so a position-only change that reorders them must invalidate the cache
+ * even though every node/edge identity is unchanged. Rather than fingerprint
+ * raw coordinates (which would invalidate the cache on every drag even when
+ * relative order is untouched), each scope's RESOLVED entry-id order is
+ * encoded, using the same default sequence-edge predicate `projectSequence`
+ * falls back to; a caller-supplied `isSequenceEdge` may classify a handful of
+ * edges differently, but the ordering signal only needs to invalidate the
+ * cache when it changes, not reproduce the projection exactly.
+ *
+ * Sorting each channel makes the fingerprint order-independent, so reordering
+ * the input arrays without changing structure keeps the cache warm.
+ */
+export function sequenceFingerprint(
+ nodes: Node[],
+ edges: Edge[],
+ collapsed: ReadonlySet
+): string {
+ const nodeParts = nodes
+ .map((node) => JSON.stringify([node.id, node.type ?? '', node.parentId ?? '']))
+ .sort();
+ const edgeParts = edges
+ .map((edge) =>
+ JSON.stringify([
+ edge.id,
+ edge.source,
+ edge.sourceHandle ?? '',
+ edge.target,
+ edge.targetHandle ?? '',
+ isSequentialContinuationEdge(edge),
+ ])
+ )
+ .sort();
+ const collapsedParts = [...collapsed].sort().map((id) => JSON.stringify(id));
+
+ const index = buildGraphIndex(nodes, edges, defaultIsSequenceEdge);
+ const orderParts = [...index.childrenByParent.entries()]
+ .map(([scope, children]) =>
+ JSON.stringify([scope ?? '', sortByFlow(children).map((node) => node.id)])
+ )
+ .sort();
+
+ return [
+ `n=${nodeParts.length}`,
+ nodeParts.join('|'),
+ `e=${edgeParts.length}`,
+ edgeParts.join('|'),
+ `c=${collapsedParts.length}`,
+ collapsedParts.join('|'),
+ `o=${orderParts.length}`,
+ orderParts.join('|'),
+ ].join('#');
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/fixtures.ts b/packages/apollo-react/src/canvas/utils/sequential/fixtures.ts
new file mode 100644
index 000000000..2c2f08033
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/fixtures.ts
@@ -0,0 +1,424 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+
+/**
+ * Shared, dependency-free graph fixtures for the sequential engine tests and for
+ * consumers' stories/tests. Every fixture is a plain
+ * `{ nodes, edges }` graph in the canonical (flow-view) shape; positions are set
+ * so flow-view-y ordering is deterministic. These are NOT re-exported from the
+ * package barrel (index.ts) so test fixtures never ship to consumers; import them
+ * directly from `utils/sequential/fixtures`.
+ */
+
+export interface GraphFixture {
+ nodes: Node[];
+ edges: Edge[];
+}
+
+interface NodeOptions {
+ parentId?: string;
+ x?: number;
+ y?: number;
+}
+
+function node(id: string, type: string, label: string, options: NodeOptions = {}): Node {
+ return {
+ id,
+ type,
+ position: { x: options.x ?? 0, y: options.y ?? 0 },
+ data: { display: { label } },
+ ...(options.parentId ? { parentId: options.parentId } : {}),
+ };
+}
+
+function edge(
+ id: string,
+ source: string,
+ sourceHandle: string | undefined,
+ target: string,
+ targetHandle: string | undefined,
+ label?: string
+): Edge {
+ return {
+ id,
+ source,
+ target,
+ sourceHandle,
+ targetHandle,
+ ...(label ? { data: { label } } : {}),
+ };
+}
+
+/**
+ * The wireframe reference graph:
+ * Trigger -> HTTP Request -> Javascript -> For Each [ If -> Then: Javascript 1 / Else: HTTP Request 1 ] -> Send Message to User
+ *
+ * `For Each` is a container; `If` is its sole body child; the If's two branches
+ * both wire back to the container's `continue` handle (loop iteration), so they
+ * dead-end at the container boundary with no in-body merge. The synthetic start
+ * bar replaces the canonical trigger in Sequential; the trailing placeholder is
+ * injected view-only. Flow renders the same trigger as its manifest-defined circle.
+ *
+ * Expected projection: 7 numbered rows (HTTP=1, Javascript=2, For Each=3, If=4,
+ * Javascript 1=5, HTTP Request 1=6, Send Message=7); depths 0,0,0,1,2,2,0; six
+ * connectors (three `step`, three `branch-entry`: Body/Then/Else); no diagnostics.
+ */
+export const WIREFRAME_NODE_IDS = {
+ trigger: 'trigger',
+ http: 'http',
+ javascript: 'javascript',
+ forEach: 'for-each',
+ ifNode: 'if',
+ thenJs: 'javascript-1',
+ elseHttp: 'http-request-1',
+ sendMessage: 'send-message',
+} as const;
+
+export function makeWireframeFixture(): GraphFixture {
+ const ids = WIREFRAME_NODE_IDS;
+ const nodes: Node[] = [
+ node(ids.trigger, 'uipath.first-run', 'Workflow start', { y: -200 }),
+ node(ids.http, 'uipath.http-request', 'HTTP Request', { y: 0 }),
+ node(ids.javascript, 'uipath.script', 'Javascript', { y: 200 }),
+ node(ids.forEach, 'uipath.control-flow.foreach', 'For Each', { y: 400 }),
+ node(ids.sendMessage, 'uipath.send-message', 'Send Message to User', { y: 600 }),
+ node(ids.ifNode, 'uipath.control-flow.decision', 'If', { parentId: ids.forEach, y: 0 }),
+ node(ids.thenJs, 'uipath.script', 'Javascript 1', { parentId: ids.forEach, y: 120 }),
+ node(ids.elseHttp, 'uipath.http-request', 'HTTP Request 1', { parentId: ids.forEach, y: 240 }),
+ ];
+ const edges: Edge[] = [
+ edge('e-trigger-http', ids.trigger, 'output', ids.http, 'input'),
+ edge('e-http-js', ids.http, 'output', ids.javascript, 'input'),
+ edge('e-js-foreach', ids.javascript, 'success', ids.forEach, 'input'),
+ edge('e-foreach-if', ids.forEach, 'start', ids.ifNode, 'input'),
+ edge('e-if-then', ids.ifNode, 'true', ids.thenJs, 'input', 'Then'),
+ edge('e-if-else', ids.ifNode, 'false', ids.elseHttp, 'input', 'Else'),
+ edge('e-then-continue', ids.thenJs, 'success', ids.forEach, 'continue'),
+ edge('e-else-continue', ids.elseHttp, 'output', ids.forEach, 'continue'),
+ edge('e-foreach-send', ids.forEach, 'success', ids.sendMessage, 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/** Two disconnected linear chains: A->B and C->D. Triggers `multi-root`. */
+export function makeMultiRootFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('b', 'uipath.script', 'B', { y: 100 }),
+ node('c', 'uipath.script', 'C', { y: 400 }),
+ node('d', 'uipath.script', 'D', { y: 500 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-b', 'a', 'output', 'b', 'input'),
+ edge('c-d', 'c', 'output', 'd', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * Two roots converging on one node (X->M, Y->M): M is placed under its first
+ * incomer (X) and Y draws a `goto`. Triggers `unstructured-merge` (and, since
+ * the two roots are independent, `multi-root`).
+ */
+export function makeUnstructuredMergeFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('x', 'uipath.script', 'X', { y: 0 }),
+ node('y', 'uipath.script', 'Y', { y: 300 }),
+ node('m', 'uipath.script', 'M', { y: 150 }),
+ ];
+ const edges: Edge[] = [
+ edge('x-m', 'x', 'output', 'm', 'input'),
+ edge('y-m', 'y', 'output', 'm', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/** A non-loopBack cycle A->B->C->A. Triggers `cycle` with a `goto` connector. */
+export function makeCycleFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('b', 'uipath.script', 'B', { y: 100 }),
+ node('c', 'uipath.script', 'C', { y: 200 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-b', 'a', 'output', 'b', 'input'),
+ edge('b-c', 'b', 'output', 'c', 'input'),
+ edge('c-a', 'c', 'output', 'a', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * A While-style loop closed by a `loopBack` target handle (A->B, B->A@loopBack).
+ * The loopBack edge is consumed as loop closure, so this is NOT a cycle.
+ */
+export function makeLoopBackFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.control-flow.while', 'While', { y: 0 }),
+ node('b', 'uipath.script', 'Body', { y: 100 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-b', 'a', 'body', 'b', 'input'),
+ edge('b-a', 'b', 'output', 'a', 'loopBack'),
+ ];
+ return { nodes, edges };
+}
+
+/** A linear chain plus a fully disconnected node Z. Triggers `orphan`. */
+export function makeOrphanFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('b', 'uipath.script', 'B', { y: 100 }),
+ node('z', 'uipath.script', 'Z', { y: 900 }),
+ ];
+ const edges: Edge[] = [edge('a-b', 'a', 'output', 'b', 'input')];
+ return { nodes, edges };
+}
+
+/**
+ * Nested containers: Root -> C1 [ C2 [ Leaf ] ]. Exercises depth accumulation
+ * across container nesting (Root/C1 depth 0, C2 depth 1, Leaf depth 2).
+ */
+export function makeDeepNestingFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('root', 'uipath.script', 'Root', { y: 0 }),
+ node('c1', 'uipath.control-flow.foreach', 'Outer', { y: 100 }),
+ node('c2', 'uipath.control-flow.foreach', 'Inner', { parentId: 'c1', y: 0 }),
+ node('leaf', 'uipath.script', 'Leaf', { parentId: 'c2', y: 0 }),
+ ];
+ const edges: Edge[] = [
+ edge('root-c1', 'root', 'output', 'c1', 'input'),
+ edge('c1-c2', 'c1', 'start', 'c2', 'input'),
+ edge('c2-leaf', 'c2', 'start', 'leaf', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * A Decision whose `false` branch wires straight to the join (empty Else lane):
+ * A -> If; If.true -> B -> C; If.false -> C. Produces an empty-branch-body slot
+ * for the Else lane and a `merge-back` from B to C.
+ */
+export function makeEmptyBranchFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('if', 'uipath.control-flow.decision', 'If', { y: 100 }),
+ node('b', 'uipath.script', 'B', { y: 200 }),
+ node('c', 'uipath.script', 'C', { y: 300 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-if', 'a', 'output', 'if', 'input'),
+ edge('if-b', 'if', 'true', 'b', 'input', 'Then'),
+ edge('if-c', 'if', 'false', 'c', 'input', 'Else'),
+ edge('b-c', 'b', 'output', 'c', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * An if-inside-if with an empty else at both levels: A -> If1; If1.true -> If2;
+ * If2.true -> B -> M; If2.false -> C -> M; If1.false -> M; M -> Z. The nested
+ * If2's own merge (M) must be resolved before If1's outer merge detection can
+ * see that both of ITS lanes (the If2 subtree, and the direct false edge) join
+ * at M. Regression fixture for the nested-branch post-dominator fix.
+ */
+export function makeNestedBranchFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('if1', 'uipath.control-flow.decision', 'If1', { y: 100 }),
+ node('if2', 'uipath.control-flow.decision', 'If2', { y: 200 }),
+ node('b', 'uipath.script', 'B', { y: 300 }),
+ node('c', 'uipath.script', 'C', { y: 400 }),
+ node('m', 'uipath.script', 'M', { y: 500 }),
+ node('z', 'uipath.script', 'Z', { y: 600 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-if1', 'a', 'output', 'if1', 'input'),
+ edge('if1-if2', 'if1', 'true', 'if2', 'input', 'Then'),
+ edge('if1-m', 'if1', 'false', 'm', 'input', 'Else'),
+ edge('if2-b', 'if2', 'true', 'b', 'input', 'Then'),
+ edge('if2-c', 'if2', 'false', 'c', 'input', 'Else'),
+ edge('b-m', 'b', 'output', 'm', 'input'),
+ edge('c-m', 'c', 'output', 'm', 'input'),
+ edge('m-z', 'm', 'output', 'z', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * A cycle closed through one of a branch node's two out-edges: A -> B;
+ * B.true -> A (closes the cycle); B.false -> D. Regression fixture for
+ * classifying branch-edge targets (onStack/visited) before rendering them as
+ * an insertable branch-entry lane.
+ */
+export function makeBranchCycleFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('b', 'uipath.control-flow.decision', 'B', { y: 100 }),
+ node('d', 'uipath.script', 'D', { y: 200 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-b', 'a', 'output', 'b', 'input'),
+ edge('b-a', 'b', 'true', 'a', 'input', 'Retry'),
+ edge('b-d', 'b', 'false', 'd', 'input', 'Done'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * Two independent roots (X, Y) whose branch edges both jump straight to an
+ * already-visited node (M is placed by X's lane; Y's branch edge then targets
+ * M too): X -> If; If.true -> M; If.false -> M2; Y -> M. Regression fixture
+ * for a branch edge landing on a node visited by an unrelated earlier lane.
+ */
+export function makeBranchVisitedTargetFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('x', 'uipath.script', 'X', { y: 0 }),
+ node('if', 'uipath.control-flow.decision', 'If', { y: 100 }),
+ node('m', 'uipath.script', 'M', { y: 200 }),
+ node('m2', 'uipath.script', 'M2', { y: 300 }),
+ node('y', 'uipath.script', 'Y', { y: 400 }),
+ ];
+ const edges: Edge[] = [
+ edge('x-if', 'x', 'output', 'if', 'input'),
+ edge('if-m', 'if', 'true', 'm', 'input', 'Then'),
+ edge('if-m2', 'if', 'false', 'm2', 'input', 'Else'),
+ edge('y-m', 'y', 'output', 'm', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/** A lone node with zero edges: the simplest possible valid sequence. */
+export function makeSingleNodeFixture(): GraphFixture {
+ return { nodes: [node('only', 'uipath.script', 'Only', { y: 0 })], edges: [] };
+}
+
+/**
+ * Two independent components with no zero-in-degree entry (each a 2-node
+ * cycle, so every node has in-degree 1) plus a fully isolated orphan
+ * positioned, in flow-view y, BETWEEN the two components. Regression fixture
+ * proving orphans always land in a trailing section instead of splitting two
+ * components' rows apart.
+ */
+export function makeInterleavedOrphanFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('p1', 'uipath.script', 'P1', { y: 0 }),
+ node('p2', 'uipath.script', 'P2', { y: 50 }),
+ node('orphan', 'uipath.script', 'Orphan', { y: 75 }),
+ node('q1', 'uipath.script', 'Q1', { y: 100 }),
+ node('q2', 'uipath.script', 'Q2', { y: 150 }),
+ ];
+ const edges: Edge[] = [
+ edge('p1-p2', 'p1', 'output', 'p2', 'input'),
+ edge('p2-p1', 'p2', 'output', 'p1', 'input'),
+ edge('q1-q2', 'q1', 'output', 'q2', 'input'),
+ edge('q2-q1', 'q2', 'output', 'q1', 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * A plain (non-branching) container body flanked by top-level siblings:
+ * A -> Container [ X -> Y ] -> B. Used for indent/outdent/moveSubtree tests
+ * that need an unambiguous body "tail" - unlike the wireframe's `For Each`,
+ * whose sole body content (`If`) dead-ends via a hidden loop-closing edge and
+ * has no single linear tail to append after.
+ */
+export const CONTAINER_CHAIN_NODE_IDS = {
+ a: 'chain-a',
+ container: 'chain-container',
+ x: 'chain-x',
+ y: 'chain-y',
+ b: 'chain-b',
+} as const;
+
+export function makeContainerChainFixture(): GraphFixture {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const nodes: Node[] = [
+ node(ids.a, 'uipath.script', 'A', { y: 0 }),
+ node(ids.container, 'uipath.control-flow.foreach', 'Container', { y: 100 }),
+ node(ids.b, 'uipath.script', 'B', { y: 300 }),
+ node(ids.x, 'uipath.script', 'X', { parentId: ids.container, y: 0 }),
+ node(ids.y, 'uipath.script', 'Y', { parentId: ids.container, y: 100 }),
+ ];
+ const edges: Edge[] = [
+ edge('chain-a-container', ids.a, 'output', ids.container, 'input'),
+ edge('chain-container-x', ids.container, 'start', ids.x, 'input'),
+ edge('chain-x-y', ids.x, 'output', ids.y, 'input'),
+ edge('chain-container-b', ids.container, 'success', ids.b, 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * Two top-level containers: C1 holds a Decision (`If`) whose Then/Else
+ * branches dead-end (no shared merge, mirroring the wireframe's loop-body
+ * idiom); C2 holds a single plain leaf (`Filler`) - NOT a truly childless
+ * container, since this structural engine has no manifest access and can
+ * only recognize a node as a container via `isContainerNode` (at least one
+ * OTHER node pointing at it via `parentId`; see graph-helpers.ts). Root -> C1
+ * -> C2 -> Tail. Purpose-built to exercise `moveSubtree` relocating a BARE
+ * BRANCH OWNER (not itself a container) together with its lane content
+ * across a container boundary (`If`'s subtree moves from C1's body to
+ * append after C2's `Filler`) via a source-only slot, so the move only ADDS
+ * an incoming edge to `If` and never a competing outgoing one - see
+ * moveSubtree's doc comment on why splicing a new OUTGOING edge onto a bare
+ * branch owner is unsound (it always reads as a third lane, not a "next
+ * step").
+ */
+export const CROSS_CONTAINER_BRANCH_NODE_IDS = {
+ root: 'cc-root',
+ c1: 'cc-c1',
+ ifNode: 'cc-if',
+ thenLeaf: 'cc-then',
+ elseLeaf: 'cc-else',
+ c2: 'cc-c2',
+ filler: 'cc-filler',
+ tail: 'cc-tail',
+} as const;
+
+export function makeCrossContainerBranchFixture(): GraphFixture {
+ const ids = CROSS_CONTAINER_BRANCH_NODE_IDS;
+ const nodes: Node[] = [
+ node(ids.root, 'uipath.script', 'Root', { y: 0 }),
+ node(ids.c1, 'uipath.control-flow.foreach', 'C1', { y: 100 }),
+ node(ids.c2, 'uipath.control-flow.foreach', 'C2', { y: 300 }),
+ node(ids.tail, 'uipath.script', 'Tail', { y: 500 }),
+ node(ids.ifNode, 'uipath.control-flow.decision', 'If', { parentId: ids.c1, y: 0 }),
+ node(ids.thenLeaf, 'uipath.script', 'Then', { parentId: ids.c1, y: 100 }),
+ node(ids.elseLeaf, 'uipath.script', 'Else', { parentId: ids.c1, y: 200 }),
+ node(ids.filler, 'uipath.script', 'Filler', { parentId: ids.c2, y: 0 }),
+ ];
+ const edges: Edge[] = [
+ edge('cc-root-c1', ids.root, 'output', ids.c1, 'input'),
+ edge('cc-c1-if', ids.c1, 'start', ids.ifNode, 'input'),
+ edge('cc-if-then', ids.ifNode, 'true', ids.thenLeaf, 'input', 'Then'),
+ edge('cc-if-else', ids.ifNode, 'false', ids.elseLeaf, 'input', 'Else'),
+ edge('cc-c2-filler', ids.c2, 'start', ids.filler, 'input'),
+ edge('cc-c1-c2', ids.c1, 'success', ids.c2, 'input'),
+ edge('cc-c2-tail', ids.c2, 'success', ids.tail, 'input'),
+ ];
+ return { nodes, edges };
+}
+
+/**
+ * A single-root diamond that findMerge resolves cleanly: A -> If;
+ * If.true -> B -> D; If.false -> C -> D. D is the immediate post-dominator;
+ * both lane tails draw `merge-back` connectors into it, D is emitted once.
+ */
+export function makeDiamondFixture(): GraphFixture {
+ const nodes: Node[] = [
+ node('a', 'uipath.script', 'A', { y: 0 }),
+ node('if', 'uipath.control-flow.decision', 'If', { y: 100 }),
+ node('b', 'uipath.script', 'B', { y: 200 }),
+ node('c', 'uipath.script', 'C', { y: 300 }),
+ node('d', 'uipath.script', 'D', { y: 400 }),
+ ];
+ const edges: Edge[] = [
+ edge('a-if', 'a', 'output', 'if', 'input'),
+ edge('if-b', 'if', 'true', 'b', 'input', 'Then'),
+ edge('if-c', 'if', 'false', 'c', 'input', 'Else'),
+ edge('b-d', 'b', 'output', 'd', 'input'),
+ edge('c-d', 'c', 'output', 'd', 'input'),
+ ];
+ return { nodes, edges };
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/graph-helpers.ts b/packages/apollo-react/src/canvas/utils/sequential/graph-helpers.ts
new file mode 100644
index 000000000..f4b64cf6c
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/graph-helpers.ts
@@ -0,0 +1,181 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_SOURCE_HANDLE_ID, PREVIEW_NODE_ID } from '../../constants';
+
+/**
+ * Internal pure graph helpers shared by the projection, merge analysis, and
+ * layout passes. Nothing here is part of the public sequential API (kept out of
+ * index.ts); it is imported directly by projectSequence / mergeAnalysis.
+ *
+ * The engine has zero manifest/registry access: it reasons purely from the
+ * canonical `nodes`/`edges` arrays. Two structural nesting mechanisms exist:
+ * - container nesting via `node.parentId` (a node with children is a container);
+ * - branch nesting via a node with more than one in-scope forward edge.
+ */
+
+/** Loop-closure target handle; consumed as loop closure, never a cycle (useEdgePath.ts:114). */
+export const LOOP_BACK_HANDLE_ID = 'loopBack';
+
+/**
+ * Canonical edge-data marker for a structural continuation through an inserted
+ * node. It prevents a downstream edge from being reinterpreted as one of a
+ * newly inserted decision/container node's own lanes merely because the Add
+ * Node pipeline selected that node's default source handle.
+ */
+export const SEQ_CONTINUATION_EDGE_KEY = '__sequentialContinuation';
+
+export function isSequentialContinuationEdge(edge: Edge): boolean {
+ return (edge.data as Record | undefined)?.[SEQ_CONTINUATION_EDGE_KEY] === true;
+}
+
+/**
+ * Prefix for synthetic empty-branch-lane placeholder node ids (a parent's
+ * declared branch handle with no child yet). These rows are view-only: they
+ * never appear in canonical state. Change forwarding receives the exact set of
+ * projected synthetic ids, so a canonical id that happens to share this prefix
+ * is still handled normally.
+ */
+export const SEQ_LANE_PLACEHOLDER_PREFIX = '__sequential-lane__';
+/** Deterministic id for a parent node's empty-lane placeholder on a given handle. */
+export const lanePlaceholderId = (parentId: string, handleId: string): string =>
+ `${SEQ_LANE_PLACEHOLDER_PREFIX}${parentId}::${handleId}`;
+/** Deterministic id for the append placeholder after a populated branch tail. */
+export const leafPlaceholderId = (leafId: string): string =>
+ `${SEQ_LANE_PLACEHOLDER_PREFIX}${leafId}::__tail__`;
+
+/** A scope is the `parentId` of the nodes being walked (`undefined` = top level). */
+export type Scope = string | undefined;
+
+export interface GraphIndex {
+ nodesById: Map;
+ /** parentId (or `undefined` for top level) -> child nodes, in input order. */
+ childrenByParent: Map;
+ /** Sequence edges only: non-preview and passing `isSequenceEdge`. Retains loopBack. */
+ seqEdges: Edge[];
+ /** Sequence edges bucketed by source id, input order preserved. */
+ seqEdgesBySource: Map;
+ /** Cache of in-degree maps per scope (sibling-forward edges, loopBack excluded). */
+ inDegreeCache: Map>;
+}
+
+/** True for loop-closure edges (target handle `loopBack`); excluded from forward walks. */
+export function isLoopBackEdge(edge: Edge): boolean {
+ return edge.targetHandle === LOOP_BACK_HANDLE_ID;
+}
+
+/**
+ * Default sequence-edge predicate. Mirrors the container-engine contract
+ * (utils/container.ts:766) where the predicate is caller-supplied: without a
+ * manifest registry this layer cannot resolve `handleType === 'artifact'`, so
+ * every non-preview edge counts as a sequence edge. Callers with registry
+ * access pass `isSequenceEdge` to exclude artifact/resource handles.
+ */
+export function defaultIsSequenceEdge(edge: Edge): boolean {
+ return edge.source !== PREVIEW_NODE_ID && edge.target !== PREVIEW_NODE_ID;
+}
+
+export function buildGraphIndex(
+ nodes: Node[],
+ edges: Edge[],
+ isSequenceEdge: (edge: Edge) => boolean
+): GraphIndex {
+ const nodesById = new Map();
+ const childrenByParent = new Map();
+ for (const node of nodes) {
+ nodesById.set(node.id, node);
+ const bucket = childrenByParent.get(node.parentId);
+ if (bucket) bucket.push(node);
+ else childrenByParent.set(node.parentId, [node]);
+ }
+
+ const seqEdges: Edge[] = [];
+ const seqEdgesBySource = new Map();
+ for (const edge of edges) {
+ if (edge.source === PREVIEW_NODE_ID || edge.target === PREVIEW_NODE_ID) continue;
+ if (!isSequenceEdge(edge)) continue;
+ seqEdges.push(edge);
+ const bucket = seqEdgesBySource.get(edge.source);
+ if (bucket) bucket.push(edge);
+ else seqEdgesBySource.set(edge.source, [edge]);
+ }
+
+ return { nodesById, childrenByParent, seqEdges, seqEdgesBySource, inDegreeCache: new Map() };
+}
+
+/** A node with at least one child is treated as a container (structural). */
+export function isContainerNode(index: GraphIndex, nodeId: string): boolean {
+ return (index.childrenByParent.get(nodeId)?.length ?? 0) > 0;
+}
+
+/**
+ * Forward sibling edges from `nodeId` inside `scope`: source and target both
+ * live directly in `scope`, loopBack excluded. Edges that wire back to the
+ * container itself (target === scope) or leave the scope are not forward steps.
+ */
+export function forwardOut(index: GraphIndex, nodeId: string, scope: Scope): Edge[] {
+ const out = index.seqEdgesBySource.get(nodeId);
+ if (!out) return [];
+ return out.filter(
+ (edge) => !isLoopBackEdge(edge) && index.nodesById.get(edge.target)?.parentId === scope
+ );
+}
+
+/** More than one forward sibling edge makes a node a branch source. */
+export function isBranchSource(index: GraphIndex, nodeId: string, scope: Scope): boolean {
+ return forwardOut(index, nodeId, scope).length > 1;
+}
+
+/** In-scope forward in-degree per node (sibling-forward edges, loopBack excluded). */
+export function inDegreeInScope(index: GraphIndex, scope: Scope): Map {
+ const cached = index.inDegreeCache.get(scope);
+ if (cached) return cached;
+ const indeg = new Map();
+ for (const edge of index.seqEdges) {
+ if (isLoopBackEdge(edge)) continue;
+ const source = index.nodesById.get(edge.source);
+ const target = index.nodesById.get(edge.target);
+ if (source?.parentId === scope && target?.parentId === scope) {
+ indeg.set(edge.target, (indeg.get(edge.target) ?? 0) + 1);
+ }
+ }
+ index.inDegreeCache.set(scope, indeg);
+ return indeg;
+}
+
+function flowOrder(a: Node, b: Node): number {
+ const ay = a.position?.y ?? 0;
+ const by = b.position?.y ?? 0;
+ if (ay !== by) return ay - by;
+ const ax = a.position?.x ?? 0;
+ const bx = b.position?.x ?? 0;
+ if (ax !== bx) return ax - bx;
+ return a.id.localeCompare(b.id);
+}
+
+/**
+ * Entry nodes for a scope: direct children with zero in-scope forward in-degree
+ * (no sibling points at them), ordered by flow-view y so multi-root lanes stack
+ * top-to-bottom (D9). Container-boundary edges from the parent do not count as
+ * sibling incomers, so a container body's first child is correctly an entry.
+ */
+export function entriesForScope(index: GraphIndex, scope: Scope): Node[] {
+ const children = index.childrenByParent.get(scope) ?? [];
+ if (children.length === 0) return [];
+ const indeg = inDegreeInScope(index, scope);
+ return children.filter((child) => (indeg.get(child.id) ?? 0) === 0).sort(flowOrder);
+}
+
+/** Sort a plain node list by flow-view order (stable, deterministic). */
+export function sortByFlow(nodes: Node[]): Node[] {
+ return [...nodes].sort(flowOrder);
+}
+
+/** Reads the optional `label` off an edge's open `data` bag. */
+export function edgeDataLabel(edge: Edge): string | undefined {
+ const label = (edge.data as { label?: string | null } | undefined)?.label;
+ return typeof label === 'string' ? label : undefined;
+}
+
+/** Source handle id or the canvas default (`'output'`). */
+export function sourceHandleOf(edge: Edge): string {
+ return edge.sourceHandle ?? DEFAULT_SOURCE_HANDLE_ID;
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/index.ts b/packages/apollo-react/src/canvas/utils/sequential/index.ts
new file mode 100644
index 000000000..1ce0053c4
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/index.ts
@@ -0,0 +1,7 @@
+export * from './compatibility';
+export * from './fingerprint';
+export * from './layoutSequence';
+export * from './mutations';
+export * from './projectSequence';
+export * from './sequential.types';
+export * from './slotNavigation';
diff --git a/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.test.ts b/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.test.ts
new file mode 100644
index 000000000..44b08eca9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.test.ts
@@ -0,0 +1,268 @@
+import { describe, expect, it } from 'vitest';
+import { SEQ_BAR_HEIGHT, SEQ_BAR_WIDTH, SEQ_INDENT_PX, SEQ_ROW_GAP } from '../../constants';
+import {
+ makeDiamondFixture,
+ makeOrphanFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from './fixtures';
+import { layoutSequence } from './layoutSequence';
+import { projectSequence } from './projectSequence';
+import type { SequenceConnector, SequenceProjection } from './sequential.types';
+
+const PITCH = SEQ_BAR_HEIGHT + SEQ_ROW_GAP; // 72 + 48 = 120
+
+function connector(
+ projection: SequenceProjection,
+ source: string,
+ target: string
+): SequenceConnector {
+ const found = projection.connectors.find(
+ (c) => c.sourceRowId === source && c.targetRowId === target
+ );
+ if (!found) throw new Error(`connector ${source}->${target} not found`);
+ return found;
+}
+
+describe('layoutSequence', () => {
+ const ids = WIREFRAME_NODE_IDS;
+
+ describe('wireframe geometry', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const layout = layoutSequence(projectSequence(nodes, edges));
+
+ it('stacks visible rows at a fixed pitch and indents by depth', () => {
+ expect(layout.positions.get(ids.http)).toEqual({ x: 0, y: 0 });
+ expect(layout.positions.get(ids.javascript)).toEqual({ x: 0, y: PITCH });
+ expect(layout.positions.get(ids.forEach)).toEqual({ x: 0, y: 2 * PITCH });
+ expect(layout.positions.get(ids.ifNode)).toEqual({ x: SEQ_INDENT_PX, y: 3 * PITCH });
+ expect(layout.positions.get(ids.thenJs)).toEqual({ x: 2 * SEQ_INDENT_PX, y: 4 * PITCH });
+ expect(layout.positions.get(ids.elseHttp)).toEqual({ x: 2 * SEQ_INDENT_PX, y: 5 * PITCH });
+ expect(layout.positions.get(ids.sendMessage)).toEqual({ x: 0, y: 6 * PITCH });
+ });
+
+ it('computes bounds spanning the deepest and lowest rows', () => {
+ expect(layout.bounds).toEqual({
+ x: 0,
+ y: 0,
+ width: 2 * SEQ_INDENT_PX + SEQ_BAR_WIDTH,
+ height: 6 * PITCH + SEQ_BAR_HEIGHT,
+ });
+ });
+ });
+
+ describe('collapse closes the vertical gap', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const layout = layoutSequence(
+ projectSequence(nodes, edges, { collapsedStepIds: new Set([ids.forEach]) })
+ );
+
+ it('advances y only over visible rows', () => {
+ expect(layout.positions.get(ids.http)).toEqual({ x: 0, y: 0 });
+ expect(layout.positions.get(ids.javascript)).toEqual({ x: 0, y: PITCH });
+ expect(layout.positions.get(ids.forEach)).toEqual({ x: 0, y: 2 * PITCH });
+ // sendMessage follows immediately after the collapsed container.
+ expect(layout.positions.get(ids.sendMessage)).toEqual({ x: 0, y: 3 * PITCH });
+ });
+
+ it('parks hidden rows at their collapsed ancestor y', () => {
+ const containerY = layout.positions.get(ids.forEach)?.y;
+ expect(layout.positions.get(ids.ifNode)?.y).toBe(containerY);
+ expect(layout.positions.get(ids.thenJs)?.y).toBe(containerY);
+ });
+
+ it('excludes hidden rows from the bounds height', () => {
+ expect(layout.bounds.height).toBe(3 * PITCH + SEQ_BAR_HEIGHT);
+ });
+ });
+
+ describe('merge-back waypoints', () => {
+ const { nodes, edges } = makeDiamondFixture();
+ const projection = projectSequence(nodes, edges);
+ const layout = layoutSequence(projection);
+
+ it('routes an earlier branch through source-exit, corridor, and target-entry waypoints', () => {
+ const waypoints = layout.connectorWaypoints.get(connector(projection, 'b', 'd').id);
+ expect(waypoints).toHaveLength(4);
+ // The middle pair is the vertical corridor; the outer pair align with
+ // the source and target handles so neither stub loops through a row.
+ expect(waypoints?.[1]?.x).toBe(waypoints?.[2]?.x);
+ expect(waypoints?.[0]?.y).toBe(waypoints?.[1]?.y);
+ expect(waypoints?.[2]?.y).toBe(waypoints?.[3]?.y);
+ expect(waypoints?.every((w) => typeof w.id === 'string')).toBe(true);
+ });
+
+ it('routes the lowest branch directly into the target entry without doubling back', () => {
+ const waypoints = layout.connectorWaypoints.get(connector(projection, 'c', 'd').id);
+ expect(waypoints).toHaveLength(2);
+ expect(waypoints?.[0]?.y).toBe(waypoints?.[1]?.y);
+ expect(waypoints?.[0]?.x ?? 0).toBeGreaterThan(waypoints?.[1]?.x ?? 0);
+ });
+
+ it('bundles branches converging on one target into a shared join trunk', () => {
+ const first = layout.connectorWaypoints.get(connector(projection, 'b', 'd').id);
+ const second = layout.connectorWaypoints.get(connector(projection, 'c', 'd').id);
+
+ // Both paths meet at one target-entry point and share only the clean final
+ // stem into D. The lower branch never traverses the outer corridor.
+ expect(first?.[3]).toMatchObject({ x: second?.[1]?.x, y: second?.[1]?.y });
+ });
+
+ it('routes the corridor strictly left of every row it could cross (F5 regression)', () => {
+ // The diamond fixture's b->d merge-back has to descend past sibling row
+ // c (same depth, between b and d in row order); the old implementation
+ // dropped straight down b's own column, straight through c's bar.
+ const waypoints = layout.connectorWaypoints.get(connector(projection, 'b', 'd').id);
+ const corridorX = waypoints?.[1]?.x ?? Number.POSITIVE_INFINITY;
+ const allRowLeftEdges = projection.rows.map((r) => layout.positions.get(r.nodeId)?.x ?? 0);
+ for (const rowX of allRowLeftEdges) {
+ expect(corridorX).toBeLessThan(rowX);
+ }
+ });
+
+ it('handle-routes step connectors (no waypoints) but elbows every branch lane', () => {
+ for (const c of projection.connectors) {
+ if (c.kind === 'step') {
+ expect(layout.connectorWaypoints.has(c.id)).toBe(false);
+ }
+ }
+ // Both lanes (first: If.true -> B, second: If.false -> C) now carry the
+ // shared-spine elbow: a vertical drop then a horizontal jog (wp0.y == wp1.y).
+ for (const target of ['b', 'c']) {
+ const waypoints = layout.connectorWaypoints.get(connector(projection, 'if', target).id);
+ expect(waypoints, target).toHaveLength(2);
+ expect(waypoints?.[0]?.y).toBe(waypoints?.[1]?.y);
+ }
+ });
+
+ it('shares one vertical spine (the owner handle column) across all lanes', () => {
+ // Every lane drops down the SAME column (the owner's own source-handle x),
+ // so Then and Else trace the same shape; the later lane just turns farther
+ // down. This is what makes the two branches read as siblings.
+ const first = layout.connectorWaypoints.get(connector(projection, 'if', 'b').id);
+ const second = layout.connectorWaypoints.get(connector(projection, 'if', 'c').id);
+ expect(first?.[0]?.x).toBe(second?.[0]?.x);
+ expect(second?.[0]?.y ?? 0).toBeGreaterThan(first?.[0]?.y ?? 0);
+ });
+ });
+
+ describe('container-continuation merge-back waypoints (wireframe, defect 5 regression)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const layout = layoutSequence(projection);
+
+ it('drops For Each -> Send Message straight down the shared spine (no corridor jog)', () => {
+ // Unlike a branch-lane merge-back (source deeper than target, which
+ // corridors left to clear sibling bars), this connector's source (the
+ // container row) and target (the next spine row) sit at the SAME depth,
+ // so their handles already share one spine column and the body rows
+ // between them (If, Javascript 1, HTTP Request 1) are all strictly
+ // deeper (to the right). Routing it through the left corridor jogged the
+ // dashed line out past the spine and read as broken; it must instead
+ // drop straight (no waypoints) between the two aligned handles.
+ const c = connector(projection, ids.forEach, ids.sendMessage);
+ expect(c.kind).toBe('merge-back');
+ expect(layout.connectorWaypoints.has(c.id)).toBe(false);
+
+ const forEachPos = layout.positions.get(ids.forEach);
+ const sendMessagePos = layout.positions.get(ids.sendMessage);
+ expect(forEachPos?.x).toBe(sendMessagePos?.x);
+ });
+
+ it('does not add waypoints to the still-plain step connectors', () => {
+ expect(
+ layout.connectorWaypoints.has(connector(projection, ids.http, ids.javascript).id)
+ ).toBe(false);
+ expect(
+ layout.connectorWaypoints.has(connector(projection, ids.javascript, ids.forEach).id)
+ ).toBe(false);
+ });
+ });
+
+ describe('branch-entry waypoints never cross a sibling lane’s bar (wireframe regression)', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const layout = layoutSequence(projection);
+
+ /** A row's occupied rectangle, for intersection checks against a corridor run. */
+ function rowBand(nodeId: string): { left: number; right: number; top: number; bottom: number } {
+ const position = layout.positions.get(nodeId);
+ if (!position) throw new Error(`no position for ${nodeId}`);
+ return {
+ left: position.x,
+ right: position.x + SEQ_BAR_WIDTH,
+ top: position.y,
+ bottom: position.y + SEQ_BAR_HEIGHT,
+ };
+ }
+
+ it('routes every branch lane (spine drop + gap jog) clear of all other rows', () => {
+ const branchEntries = projection.connectors.filter((c) => c.kind === 'branch-entry');
+ let checked = 0;
+
+ for (const c of branchEntries) {
+ const waypoints = layout.connectorWaypoints.get(c.id);
+ expect(waypoints, c.id).toHaveLength(2);
+ if (!waypoints) continue;
+ checked++;
+ const source = layout.positions.get(c.sourceRowId);
+ if (!source) throw new Error(`no position for ${c.sourceRowId}`);
+ const [wp0, wp1] = waypoints;
+
+ // Vertical spine at wp0.x, from the owner's bottom handle down to the jog.
+ const spineX = wp0?.x ?? Number.NaN;
+ const spineTop = Math.min(source.y + SEQ_BAR_HEIGHT, wp0?.y ?? 0);
+ const spineBottom = Math.max(source.y + SEQ_BAR_HEIGHT, wp0?.y ?? 0);
+ // Horizontal jog at wp0.y, from wp0.x to wp1.x (sits in the gap band
+ // above the target, where no row lives).
+ const jogY = wp0?.y ?? Number.NaN;
+ const jogLeft = Math.min(wp0?.x ?? 0, wp1?.x ?? 0);
+ const jogRight = Math.max(wp0?.x ?? 0, wp1?.x ?? 0);
+
+ const otherRowIds = projection.rows
+ .map((r) => r.nodeId)
+ .filter((id) => id !== c.sourceRowId && id !== c.targetRowId);
+
+ for (const nodeId of otherRowIds) {
+ const band = rowBand(nodeId);
+ const spineCrosses =
+ spineX >= band.left &&
+ spineX <= band.right &&
+ spineBottom > band.top &&
+ spineTop < band.bottom;
+ const jogCrosses =
+ jogY > band.top && jogY < band.bottom && jogRight > band.left && jogLeft < band.right;
+ expect(spineCrosses || jogCrosses, `${c.id} crosses ${nodeId}'s band`).toBe(false);
+ }
+ }
+
+ expect(checked).toBeGreaterThan(0); // guards against a vacuous pass
+ });
+ });
+
+ describe('custom geometry options', () => {
+ it('honours overridden bar/gap/indent dimensions', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const layout = layoutSequence(projectSequence(nodes, edges), {
+ barWidth: 400,
+ barHeight: 40,
+ rowGap: 10,
+ indent: 20,
+ });
+ // Rows are http(0), javascript(1), forEach(2), ifNode(3), ... at pitch 50.
+ expect(layout.positions.get(ids.javascript)).toEqual({ x: 0, y: 50 });
+ expect(layout.positions.get(ids.ifNode)).toEqual({ x: 20, y: 150 });
+ expect(layout.bounds.width).toBe(2 * 20 + 400);
+ });
+ });
+
+ it('reserves a placeholder pitch before the trailing orphan section', () => {
+ const { nodes, edges } = makeOrphanFixture();
+ const projection = projectSequence(nodes, edges);
+ const layout = layoutSequence(projection);
+ const b = layout.positions.get('b');
+ const orphan = layout.positions.get('z');
+ expect(b).toBeDefined();
+ expect(orphan?.y).toBe((b?.y ?? 0) + PITCH * 2);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.ts b/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.ts
new file mode 100644
index 000000000..37d949fd7
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/layoutSequence.ts
@@ -0,0 +1,175 @@
+import type { XYPosition } from '@uipath/apollo-react/canvas/xyflow/react';
+import type { Waypoint } from '../../components/Edges/shared/types';
+import {
+ SEQ_BAR_HEIGHT,
+ SEQ_BAR_WIDTH,
+ SEQ_HANDLE_LEFT_OFFSET,
+ SEQ_INDENT_PX,
+ SEQ_ROW_GAP,
+} from '../../constants';
+import type {
+ LayoutSequenceOptions,
+ SequenceLayout,
+ SequenceProjection,
+ SequenceRow,
+} from './sequential.types';
+
+/**
+ * Pure geometry pass over a projection (D11 keeps this stateless so a toggle is
+ * a cheap array swap):
+ * - `x = depth * indent`;
+ * - `y` accumulates a fixed pitch (`barHeight + rowGap`) over VISIBLE rows only,
+ * so collapsing a subtree closes the gap without renumbering;
+ * - hidden rows are parked at their collapsed ancestor's y (they render with
+ * `hidden` upstream, but a position keeps xyflow happy);
+ * - `merge-back` / `goto` connectors get orthogonal elbow waypoints conforming
+ * to the Waypoint shape (components/Edges/shared/types.ts); `step` connectors
+ * are handle-routed by the edge component, so they carry none; `branch-entry`
+ * connectors all share ONE vertical spine dropping from the owner's own
+ * source-handle column, then elbow right into each target - so every lane
+ * (Then, Else, ...) traces the same shape and a lower lane simply extends
+ * farther down the shared spine before turning.
+ */
+export function layoutSequence(
+ projection: SequenceProjection,
+ options?: LayoutSequenceOptions
+): SequenceLayout {
+ const barWidth = options?.barWidth ?? SEQ_BAR_WIDTH;
+ const barHeight = options?.barHeight ?? SEQ_BAR_HEIGHT;
+ const rowGap = options?.rowGap ?? SEQ_ROW_GAP;
+ const indent = options?.indent ?? SEQ_INDENT_PX;
+ const pitch = barHeight + rowGap;
+
+ const positions = new Map();
+ const rowsById = new Map(projection.rows.map((row) => [row.nodeId, row]));
+
+ let cursorY = 0;
+ let maxRight = 0;
+ let maxBottom = 0;
+ let orphanGapInserted = false;
+
+ for (const row of projection.rows) {
+ const x = row.depth * indent;
+ if (row.visible) {
+ // Append placeholders (the trailing "+" after a lane's last step) are
+ // layout-neutral overlays: they sit in the gap just below the previous row
+ // and reserve NO vertical space, so showing/hiding the plus (e.g. toggling
+ // readonly) never moves a node.
+ if (row.placeholderKind === 'append') {
+ positions.set(row.nodeId, { x, y: cursorY - rowGap });
+ continue;
+ }
+ // Reserve one synthetic-row pitch before the trailing orphan section.
+ // SequentialCanvas places its terminal "Add step" placeholder in this
+ // gap, so visual order and DOM order are main sequence -> placeholder ->
+ // de-emphasized orphans.
+ if (row.orphan && !orphanGapInserted) {
+ cursorY += pitch;
+ orphanGapInserted = true;
+ }
+ const position = { x, y: cursorY };
+ positions.set(row.nodeId, position);
+ cursorY += pitch;
+ maxRight = Math.max(maxRight, x + barWidth);
+ maxBottom = Math.max(maxBottom, position.y + barHeight);
+ } else {
+ // Park under the nearest collapsed ancestor so hidden clones overlap it.
+ const anchorY = collapsedAncestorY(row, rowsById, positions);
+ positions.set(row.nodeId, { x, y: anchorY });
+ }
+ }
+
+ const connectorWaypoints = new Map();
+ for (const connector of projection.connectors) {
+ if (connector.kind === 'merge-back' || connector.kind === 'goto') {
+ const source = positions.get(connector.sourceRowId);
+ const target = positions.get(connector.targetRowId);
+ if (!source || !target) continue;
+ // A container's body-exit merge-back (its forward continuation after the
+ // indented body) has its source and target at the SAME depth, so both
+ // handles already sit on the same spine column and the only rows between
+ // them are the container's body -- which are indented to the RIGHT and
+ // never cross the spine. Route it straight down (no waypoints) rather
+ // than through the corridor, which would otherwise jog it out to the
+ // left of the spine and read as a broken dashed line.
+ if (connector.kind === 'merge-back' && source.x === target.x) continue;
+
+ // A branch-lane merge-back routes through a corridor to the left of the
+ // shallower endpoint. Exit below the source, traverse that corridor, and
+ // enter above the target on its real handle column. Every branch that
+ // converges on the same target therefore shares the corridor + final
+ // approach as one visual join trunk, while retaining its own canonical
+ // edge and source-side insertion slot.
+ //
+ // Gotos use the same safe orthogonal shape. They may point upward, but the
+ // explicit source/target stubs still keep the path out of either bar.
+ const laneX = Math.min(source.x, target.x) - indent / 2;
+ const sourceHandleX = source.x + SEQ_HANDLE_LEFT_OFFSET;
+ const targetHandleX = target.x + SEQ_HANDLE_LEFT_OFFSET;
+ const sourceExitY = source.y + barHeight + rowGap / 2;
+ const targetEntryY = target.y - rowGap / 2;
+
+ // The last branch normally exits into the very same gap where the target
+ // entry starts. Route it straight to that entry instead of sending it
+ // past the target handle to the outer corridor and then doubling back.
+ // Earlier branches still descend through the shared outer trunk below.
+ if (connector.kind === 'merge-back' && sourceExitY >= targetEntryY) {
+ connectorWaypoints.set(connector.id, [
+ { id: `${connector.id}:wp0`, x: sourceHandleX, y: sourceExitY },
+ { id: `${connector.id}:wp1`, x: targetHandleX, y: targetEntryY },
+ ]);
+ continue;
+ }
+
+ connectorWaypoints.set(connector.id, [
+ { id: `${connector.id}:wp0`, x: sourceHandleX, y: sourceExitY },
+ { id: `${connector.id}:wp1`, x: laneX, y: sourceExitY },
+ { id: `${connector.id}:wp2`, x: laneX, y: targetEntryY },
+ { id: `${connector.id}:wp3`, x: targetHandleX, y: targetEntryY },
+ ]);
+ continue;
+ }
+
+ if (connector.kind === 'branch-entry') {
+ const source = positions.get(connector.sourceRowId);
+ const target = positions.get(connector.targetRowId);
+ if (!source || !target) continue;
+
+ // Every branch lane shares ONE vertical spine dropping from the owner's
+ // OWN source-handle column (source.x + handle offset), then elbows right
+ // into the child's LEFT face at its vertical middle. So the first lane
+ // (Then) and every later lane (Else, ...) trace the same shape - the later
+ // lane just extends farther down the shared spine before turning - instead
+ // of each lane getting its own corridor. The horizontal turn lands on the
+ // child's mid-left (an indent read), leaving the row gap above the child
+ // clear. The spine at the owner's handle x is always strictly left of
+ // every deeper target bar's left edge (targets sit at least one indent
+ // deeper), so the vertical run never crosses an intervening bar.
+ const ownerHandleX = source.x + SEQ_HANDLE_LEFT_OFFSET;
+ const targetMidY = target.y + barHeight / 2;
+ connectorWaypoints.set(connector.id, [
+ { id: `${connector.id}:wp0`, x: ownerHandleX, y: targetMidY },
+ { id: `${connector.id}:wp1`, x: target.x, y: targetMidY },
+ ]);
+ }
+ }
+
+ const bounds = { x: 0, y: 0, width: maxRight, height: maxBottom };
+ return { positions, connectorWaypoints, bounds };
+}
+
+function collapsedAncestorY(
+ row: SequenceRow,
+ rowsById: Map,
+ positions: Map
+): number {
+ let parentId = row.parentRowId;
+ while (parentId) {
+ const parent = rowsById.get(parentId);
+ if (!parent) break;
+ const parentPosition = positions.get(parent.nodeId);
+ if (parent.visible && parentPosition) return parentPosition.y;
+ parentId = parent.parentRowId;
+ }
+ return 0;
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/mergeAnalysis.ts b/packages/apollo-react/src/canvas/utils/sequential/mergeAnalysis.ts
new file mode 100644
index 000000000..c708973bc
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/mergeAnalysis.ts
@@ -0,0 +1,91 @@
+import type { Edge } from '@uipath/apollo-react/canvas/xyflow/react';
+import { forwardOut, type GraphIndex, inDegreeInScope, type Scope } from './graph-helpers';
+
+/**
+ * Approximate immediate post-dominator ("merge point") for a branch node.
+ *
+ * A full dominator computation is overkill for the small, mostly-structured
+ * scopes the projection walks. Instead each branch is descended along its
+ * single-successor spine; a branch's "join" is the first node it reaches whose
+ * in-scope forward in-degree is >= 2 (a convergence point). When two or more
+ * branches converge on the same join, that node is the merge: lanes stop before
+ * it, it is emitted once at the branch owner's depth, and lane tails draw
+ * merge-back connectors into it. Irregular convergence (no shared join) returns
+ * `undefined`, which the projection degrades into first-incomer-wins + goto
+ * connectors (D9, precedent utils/coded-agents/d3-layout.ts:97-98).
+ *
+ * A lane's spine can itself split again (a branch nested inside a branch). Such
+ * a lane's own merge is resolved recursively (findMerge called on the nested
+ * branch's out-edges) and the walk continues from THAT merge, so a nested
+ * branch never defeats the outer branch's convergence detection. `visiting`
+ * threads a shared guard set through the whole recursive resolution so
+ * pathologically self-referencing nested branches degrade to `undefined`
+ * instead of recursing forever.
+ */
+export function findMerge(
+ index: GraphIndex,
+ branchEdges: Edge[],
+ scope: Scope,
+ indeg: Map = inDegreeInScope(index, scope),
+ visiting: Set = new Set()
+): string | undefined {
+ const joinCounts = new Map();
+
+ for (const edge of branchEdges) {
+ const join = laneJoin(index, edge.target, scope, indeg, visiting);
+ if (join !== undefined) joinCounts.set(join, (joinCounts.get(join) ?? 0) + 1);
+ }
+
+ let merge: string | undefined;
+ let mergeCount = 1;
+ for (const [nodeId, count] of joinCounts) {
+ if (count >= 2 && count > mergeCount) {
+ merge = nodeId;
+ mergeCount = count;
+ }
+ }
+ return merge;
+}
+
+/**
+ * Follows one branch's single-successor spine and returns the first convergence
+ * node (forward in-degree >= 2) it reaches, or `undefined` if the branch
+ * dead-ends, leaves scope, or loops before converging. When the spine itself
+ * splits (a nested branch), the nested branch's own merge is resolved first
+ * (recursively) and the walk resumes from it, rather than bailing out.
+ */
+function laneJoin(
+ index: GraphIndex,
+ startId: string,
+ scope: Scope,
+ indeg: Map,
+ visiting: Set
+): string | undefined {
+ const seen = new Set();
+ let current: string | undefined = startId;
+
+ while (current !== undefined) {
+ // A convergence point is recognized regardless of how we arrived at it
+ // (a direct step or a resolved nested merge), so check it first.
+ if ((indeg.get(current) ?? 0) >= 2) return current;
+ if (seen.has(current) || visiting.has(current)) return undefined; // loops without converging
+ seen.add(current);
+
+ const node = index.nodesById.get(current);
+ if (!node || node.parentId !== scope) return undefined;
+
+ const out = forwardOut(index, current, scope);
+ if (out.length === 0) return undefined; // dead-end: never converges
+ if (out.length === 1) {
+ current = out[0]!.target;
+ continue;
+ }
+
+ // Nested branch: resolve its own merge first, then continue from there.
+ visiting.add(current);
+ const nestedMerge = findMerge(index, out, scope, indeg, visiting);
+ visiting.delete(current);
+ current = nestedMerge;
+ }
+ return undefined;
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/mutations.test.ts b/packages/apollo-react/src/canvas/utils/sequential/mutations.test.ts
new file mode 100644
index 000000000..706d3d6fb
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/mutations.test.ts
@@ -0,0 +1,600 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import type { GraphFixture } from './fixtures';
+import {
+ CONTAINER_CHAIN_NODE_IDS,
+ CROSS_CONTAINER_BRANCH_NODE_IDS,
+ makeContainerChainFixture,
+ makeCrossContainerBranchFixture,
+ makeDiamondFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from './fixtures';
+import { SEQ_CONTINUATION_EDGE_KEY } from './graph-helpers';
+import { insertAtSlot, moveStep, moveSubtree, removeStep } from './mutations';
+import { projectSequence } from './projectSequence';
+import type { GraphChangeSet, InsertionSlot } from './sequential.types';
+import { findMoveDownSlot, findMoveUpSlot, findOutdentSlot } from './slotNavigation';
+
+function applyChangeSet(fixture: GraphFixture, changeSet: GraphChangeSet): GraphFixture {
+ const removeNodes = new Set(changeSet.removeNodeIds);
+ const removeEdges = new Set(changeSet.removeEdgeIds);
+ return {
+ nodes: [...fixture.nodes.filter((n) => !removeNodes.has(n.id)), ...changeSet.addNodes],
+ edges: [...fixture.edges.filter((e) => !removeEdges.has(e.id)), ...changeSet.addEdges],
+ };
+}
+
+/** Endpoint-and-node-set signature, ignoring edge ids and ordering. */
+function topologyKey(fixture: GraphFixture): string {
+ const nodeKey = fixture.nodes
+ .map((n) => n.id)
+ .sort()
+ .join(',');
+ const edgeKey = fixture.edges
+ .map((e) => `${e.source}|${e.sourceHandle ?? ''}->${e.target}|${e.targetHandle ?? ''}`)
+ .sort()
+ .join(',');
+ return `${nodeKey} # ${edgeKey}`;
+}
+
+/**
+ * Node-set-plus-parentId and edge SOURCE/TARGET (no handles) signature, for
+ * moveSubtree round-trips specifically: `moveStep`/`moveSubtree`'s splice
+ * never preserves the MOVED node's own handle on a re-spliced edge (only the
+ * OTHER endpoint's handle, copied from the target slot, survives - the
+ * engine has no manifest access to know what the moved node's real handle
+ * should be), so a moveDown-then-moveUp round-trip legitimately changes the
+ * moved node's OWN handles even though connectivity is fully restored.
+ */
+function connectivityKey(fixture: GraphFixture): string {
+ const nodeKey = fixture.nodes
+ .map((n) => `${n.id}@${n.parentId ?? ''}`)
+ .sort()
+ .join(',');
+ const edgeKey = fixture.edges
+ .map((e) => `${e.source}->${e.target}`)
+ .sort()
+ .join(',');
+ return `${nodeKey} # ${edgeKey}`;
+}
+
+function makeNode(id: string): Node {
+ return { id, type: 'uipath.script', position: { x: 0, y: 0 }, data: {} };
+}
+
+function absolutePosition(node: Node, nodes: readonly Node[]): { x: number; y: number } {
+ let x = node.position.x;
+ let y = node.position.y;
+ let parentId = node.parentId;
+ while (parentId) {
+ const parent = nodes.find((candidate) => candidate.id === parentId);
+ if (!parent) break;
+ x += parent.position.x;
+ y += parent.position.y;
+ parentId = parent.parentId;
+ }
+ return { x, y };
+}
+
+function firstStepSlot(fixture: GraphFixture): InsertionSlot {
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const stepConnector = projection.connectors.find((c) => c.kind === 'step');
+ if (!stepConnector?.slot) throw new Error('no step slot found');
+ return stepConnector.slot;
+}
+
+describe('mutations', () => {
+ describe('insertAtSlot', () => {
+ it('splits the slot edge into two around the new node', () => {
+ const fixture = makeWireframeFixture();
+ const slot = firstStepSlot(fixture); // http -> javascript
+ const changeSet = insertAtSlot(
+ projectSequence(fixture.nodes, fixture.edges),
+ slot,
+ makeNode('new')
+ );
+
+ expect(changeSet.addNodes.map((n) => n.id)).toEqual(['new']);
+ expect(changeSet.removeEdgeIds).toEqual([slot.graphEdgeId]);
+ expect(changeSet.addEdges).toHaveLength(2);
+ const [incoming, outgoing] = changeSet.addEdges as [Edge, Edge];
+ expect(incoming.source).toBe(slot.source?.nodeId);
+ expect(incoming.target).toBe('new');
+ expect(outgoing.source).toBe('new');
+ expect(outgoing.target).toBe(slot.target?.nodeId);
+ expect(
+ (incoming.data as Record | undefined)?.[SEQ_CONTINUATION_EDGE_KEY]
+ ).toBeUndefined();
+ expect((outgoing.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true);
+ });
+
+ it('preserves continuation semantics on both halves when splitting an existing continuation', () => {
+ const fixture = makeWireframeFixture();
+ const slot = { ...firstStepSlot(fixture), continuation: true };
+ const changeSet = insertAtSlot(
+ projectSequence(fixture.nodes, fixture.edges),
+ slot,
+ makeNode('new')
+ );
+
+ expect(changeSet.addEdges).toHaveLength(2);
+ for (const edge of changeSet.addEdges) {
+ expect((edge.data as Record)[SEQ_CONTINUATION_EDGE_KEY]).toBe(true);
+ }
+ });
+
+ it('stamps the container id as parentId when the slot is inside a container', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const branchSlot = projection.connectors.find((c) => c.kind === 'branch-entry')?.slot;
+ expect(branchSlot?.containerId).toBe(WIREFRAME_NODE_IDS.forEach);
+ const changeSet = insertAtSlot(projection, branchSlot!, makeNode('inserted'));
+ expect(changeSet.addNodes[0]?.parentId).toBe(WIREFRAME_NODE_IDS.forEach);
+ });
+
+ it('appends with a single edge when the slot has no target', () => {
+ const fixture = makeWireframeFixture();
+ const slot: InsertionSlot = {
+ id: 'append',
+ source: { nodeId: 'send-message', handleId: 'output' },
+ };
+ const changeSet = insertAtSlot(
+ projectSequence(fixture.nodes, fixture.edges),
+ slot,
+ makeNode('tail')
+ );
+ expect(changeSet.removeEdgeIds).toEqual([]);
+ expect(changeSet.addEdges).toHaveLength(1);
+ expect(changeSet.addEdges[0]?.source).toBe('send-message');
+ expect(changeSet.addEdges[0]?.target).toBe('tail');
+ });
+ });
+
+ describe('removeStep', () => {
+ it('heals the seam by reconnecting the incomer to the outgoer', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const changeSet = removeStep(projection, WIREFRAME_NODE_IDS.javascript);
+
+ expect(changeSet.removeNodeIds).toEqual([WIREFRAME_NODE_IDS.javascript]);
+ expect(changeSet.addEdges).toHaveLength(1);
+ expect(changeSet.addEdges[0]?.source).toBe(WIREFRAME_NODE_IDS.http);
+ expect(changeSet.addEdges[0]?.target).toBe(WIREFRAME_NODE_IDS.forEach);
+ });
+
+ it('adds no healing edge when removing a terminal node', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const changeSet = removeStep(projection, WIREFRAME_NODE_IDS.sendMessage);
+ expect(changeSet.addEdges).toEqual([]);
+ expect(changeSet.removeNodeIds).toEqual([WIREFRAME_NODE_IDS.sendMessage]);
+ });
+
+ it('cascades a container removal to its descendants and every incident raw edge (F3)', () => {
+ // Removing the wireframe's "for-each" container without passing the raw
+ // graph must still be well-defined for the single-node case, but its
+ // descendants (if / javascript-1 / http-request-1) and the body-internal
+ // "continue" edges (which never became connectors at all, since
+ // forwardOut filters them out of the forward walk) can only be cleaned
+ // up when the raw graph is supplied.
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const changeSet = removeStep(projection, WIREFRAME_NODE_IDS.forEach, fixture);
+
+ expect(new Set(changeSet.removeNodeIds)).toEqual(
+ new Set([
+ WIREFRAME_NODE_IDS.forEach,
+ WIREFRAME_NODE_IDS.ifNode,
+ WIREFRAME_NODE_IDS.thenJs,
+ WIREFRAME_NODE_IDS.elseHttp,
+ ])
+ );
+ // Every raw edge touching the container or a descendant is removed,
+ // including the "continue" edges that never appeared as connectors.
+ expect(new Set(changeSet.removeEdgeIds)).toEqual(
+ new Set([
+ 'e-js-foreach',
+ 'e-foreach-if',
+ 'e-if-then',
+ 'e-if-else',
+ 'e-then-continue',
+ 'e-else-continue',
+ 'e-foreach-send',
+ ])
+ );
+ // Only the container's own incomer/outgoer seam is healed.
+ expect(changeSet.addEdges).toHaveLength(1);
+ expect(changeSet.addEdges[0]?.source).toBe(WIREFRAME_NODE_IDS.javascript);
+ expect(changeSet.addEdges[0]?.target).toBe(WIREFRAME_NODE_IDS.sendMessage);
+
+ // The resulting graph is a clean, valid, fully-connected sequence with
+ // no dangling parentId references or edges pointing at removed nodes.
+ const healed = applyChangeSet(fixture, changeSet);
+ const survivingIds = new Set(healed.nodes.map((n) => n.id));
+ expect(survivingIds).toEqual(
+ new Set([
+ WIREFRAME_NODE_IDS.trigger,
+ WIREFRAME_NODE_IDS.http,
+ WIREFRAME_NODE_IDS.javascript,
+ WIREFRAME_NODE_IDS.sendMessage,
+ ])
+ );
+ for (const node of healed.nodes) {
+ expect(node.parentId === undefined || survivingIds.has(node.parentId)).toBe(true);
+ }
+ for (const edge of healed.edges) {
+ expect(survivingIds.has(edge.source)).toBe(true);
+ expect(survivingIds.has(edge.target)).toBe(true);
+ }
+ });
+
+ it('removes a branch owner with its projected lanes and heals to the merge', () => {
+ const fixture = makeDiamondFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const changeSet = removeStep(projection, 'if', fixture);
+
+ expect(new Set(changeSet.removeNodeIds)).toEqual(new Set(['if', 'b', 'c']));
+ expect(new Set(changeSet.removeEdgeIds)).toEqual(
+ new Set(['a-if', 'if-b', 'if-c', 'b-d', 'c-d'])
+ );
+ expect(changeSet.addEdges).toHaveLength(1);
+ expect(changeSet.addEdges[0]).toMatchObject({
+ source: 'a',
+ sourceHandle: 'output',
+ target: 'd',
+ targetHandle: 'input',
+ });
+
+ const healed = applyChangeSet(fixture, changeSet);
+ expect(healed.nodes.map((node) => node.id).sort()).toEqual(['a', 'd']);
+ expect(healed.edges).toHaveLength(1);
+ });
+
+ it('never emits synthetic empty-lane placeholder ids as canonical removals', () => {
+ const branch = { id: 'if', type: 'if', position: { x: 0, y: 0 }, data: {} };
+ const projection = projectSequence([branch], [], {
+ getBranchHandles: () => [
+ { id: 'true', label: 'Then' },
+ { id: 'false', label: 'Else' },
+ ],
+ });
+
+ expect(removeStep(projection, 'if').removeNodeIds).toEqual(['if']);
+ });
+
+ it('does not create a container self-loop when removing a branch that closes to its owner', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const changeSet = removeStep(projection, WIREFRAME_NODE_IDS.ifNode, fixture);
+
+ expect(new Set(changeSet.removeNodeIds)).toEqual(
+ new Set([WIREFRAME_NODE_IDS.ifNode, WIREFRAME_NODE_IDS.thenJs, WIREFRAME_NODE_IDS.elseHttp])
+ );
+ expect(changeSet.addEdges).toEqual([]);
+ const healed = applyChangeSet(fixture, changeSet);
+ expect(healed.edges.some((edge) => edge.source === edge.target)).toBe(false);
+ expect(healed.nodes.some((node) => node.id === WIREFRAME_NODE_IDS.forEach)).toBe(true);
+ const reprojected = projectSequence(healed.nodes, healed.edges, {
+ isContainerNode: (node) => node.id === WIREFRAME_NODE_IDS.forEach,
+ });
+ // The now-empty container body renders a "+ Add step" lane placeholder row
+ // carrying the insert slot (replacing the old bare slot:empty).
+ const laneRow = reprojected.rows.find(
+ (row) => row.lanePlaceholder?.id === `slot:lane:${WIREFRAME_NODE_IDS.forEach}:start`
+ );
+ expect(laneRow?.lanePlaceholder).toEqual({
+ id: `slot:lane:${WIREFRAME_NODE_IDS.forEach}:start`,
+ source: { nodeId: WIREFRAME_NODE_IDS.forEach, handleId: 'start' },
+ containerId: WIREFRAME_NODE_IDS.forEach,
+ });
+ });
+ });
+
+ describe('moveStep', () => {
+ it('preserves the node set (adds and removes no nodes)', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const target = firstStepSlot(fixture);
+ const changeSet = moveStep(projection, WIREFRAME_NODE_IDS.sendMessage, target);
+ expect(changeSet.addNodes).toEqual([]);
+ expect(changeSet.removeNodeIds).toEqual([]);
+ const next = applyChangeSet(fixture, changeSet);
+ expect(next.nodes.map((n) => n.id).sort()).toEqual(fixture.nodes.map((n) => n.id).sort());
+ });
+
+ it('no-ops instead of creating a self-loop when the target slot is the node own incoming edge (F7)', () => {
+ // X -> A -> B -> C; moveStep(B, slot of edge A->B) is a degenerate
+ // "move to where it already is" (reachable from Move Up at the top of a
+ // lane). It must not splice a b->b self-loop or orphan C.
+ const nodes = ['x', 'a', 'b', 'c'].map(makeNode);
+ const edges: Edge[] = [
+ { id: 'x-a', source: 'x', target: 'a', type: 'default' },
+ { id: 'a-b', source: 'a', target: 'b', type: 'default' },
+ { id: 'b-c', source: 'b', target: 'c', type: 'default' },
+ ];
+ const projection = projectSequence(nodes, edges);
+ const ownSlot = projection.connectors.find(
+ (c) => c.sourceRowId === 'a' && c.targetRowId === 'b'
+ )?.slot;
+ expect(ownSlot).toBeDefined();
+
+ const changeSet = moveStep(projection, 'b', ownSlot!);
+ expect(changeSet).toEqual({
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ });
+ });
+
+ it('no-ops when the target slot references the moved node directly', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const selfTargetSlot: InsertionSlot = {
+ id: 'probe',
+ source: { nodeId: WIREFRAME_NODE_IDS.javascript },
+ target: { nodeId: WIREFRAME_NODE_IDS.javascript },
+ };
+ const changeSet = moveStep(projection, WIREFRAME_NODE_IDS.javascript, selfTargetSlot);
+ expect(changeSet).toEqual({
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ });
+ });
+ });
+
+ describe('moveSubtree', () => {
+ it('moves a For Each with its whole body up past a preceding sibling (topology, seam, subtree intact)', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findMoveUpSlot(projection, WIREFRAME_NODE_IDS.forEach);
+ expect(slot).toBeDefined(); // http -> javascript, javascript's own incoming slot
+
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.forEach, slot!, fixture);
+ // The container node itself is untouched (no parentId change: it was
+ // and remains top-level), and none of its descendants are touched.
+ expect(changeSet.removeNodeIds).toEqual([]);
+ expect(changeSet.addNodes).toEqual([]);
+ // Origin seam healed (javascript now feeds directly into sendMessage)
+ // and target seam spliced (http -> forEach -> javascript).
+ expect(new Set(changeSet.removeEdgeIds)).toEqual(
+ new Set(['e-js-foreach', 'e-foreach-send', 'e-http-js'])
+ );
+ const bySignature = (e: Edge): string => `${e.source}->${e.target}`;
+ expect(new Set(changeSet.addEdges.map(bySignature))).toEqual(
+ new Set([
+ `${WIREFRAME_NODE_IDS.javascript}->${WIREFRAME_NODE_IDS.sendMessage}`,
+ `${WIREFRAME_NODE_IDS.http}->${WIREFRAME_NODE_IDS.forEach}`,
+ `${WIREFRAME_NODE_IDS.forEach}->${WIREFRAME_NODE_IDS.javascript}`,
+ ])
+ );
+
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ const rowOrder = movedProjection.rows.filter((r) => r.visible).map((r) => r.nodeId);
+ expect(rowOrder.slice(0, 2)).toEqual([WIREFRAME_NODE_IDS.http, WIREFRAME_NODE_IDS.forEach]);
+ expect(rowOrder).toContain(WIREFRAME_NODE_IDS.javascript);
+ expect(rowOrder).toContain(WIREFRAME_NODE_IDS.sendMessage);
+ // Descendants and body-internal branch-entry connectors are intact,
+ // and every descendant's parentId is unchanged (still forEach).
+ expect(rowOrder).toEqual(
+ expect.arrayContaining([
+ WIREFRAME_NODE_IDS.ifNode,
+ WIREFRAME_NODE_IDS.thenJs,
+ WIREFRAME_NODE_IDS.elseHttp,
+ ])
+ );
+ for (const id of [
+ WIREFRAME_NODE_IDS.ifNode,
+ WIREFRAME_NODE_IDS.thenJs,
+ WIREFRAME_NODE_IDS.elseHttp,
+ ]) {
+ expect(moved.nodes.find((n) => n.id === id)?.parentId).toBe(WIREFRAME_NODE_IDS.forEach);
+ }
+ // D7: renumbered in the new pre-order.
+ const stepNumberOf = (id: string): number | undefined =>
+ movedProjection.rows.find((r) => r.nodeId === id)?.stepNumber;
+ expect(stepNumberOf(WIREFRAME_NODE_IDS.http)).toBe(1);
+ expect(stepNumberOf(WIREFRAME_NODE_IDS.forEach)).toBe(2);
+ expect(stepNumberOf(WIREFRAME_NODE_IDS.javascript)).toBeGreaterThan(
+ stepNumberOf(WIREFRAME_NODE_IDS.forEach)!
+ );
+ });
+
+ it('moves a For Each with its whole body down past a following sibling', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findMoveDownSlot(projection, WIREFRAME_NODE_IDS.forEach);
+ expect(slot).toBeDefined();
+
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.forEach, slot!, fixture);
+ expect(changeSet.removeNodeIds).toEqual([]);
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+
+ const order = movedProjection.rows.filter((r) => r.depth === 0).map((r) => r.nodeId);
+ expect(order.indexOf(WIREFRAME_NODE_IDS.sendMessage)).toBeLessThan(
+ order.indexOf(WIREFRAME_NODE_IDS.forEach)
+ );
+ for (const id of [
+ WIREFRAME_NODE_IDS.ifNode,
+ WIREFRAME_NODE_IDS.thenJs,
+ WIREFRAME_NODE_IDS.elseHttp,
+ ]) {
+ expect(moved.nodes.find((n) => n.id === id)?.parentId).toBe(WIREFRAME_NODE_IDS.forEach);
+ }
+ });
+
+ it('round-trips: moveDown then moveUp restores the original topology', () => {
+ const base = makeWireframeFixture();
+ const baseProjection = projectSequence(base.nodes, base.edges);
+ const downSlot = findMoveDownSlot(baseProjection, WIREFRAME_NODE_IDS.forEach);
+ const down = applyChangeSet(
+ base,
+ moveSubtree(baseProjection, WIREFRAME_NODE_IDS.forEach, downSlot!, base)
+ );
+
+ const downProjection = projectSequence(down.nodes, down.edges);
+ const upSlot = findMoveUpSlot(downProjection, WIREFRAME_NODE_IDS.forEach);
+ const restored = applyChangeSet(
+ down,
+ moveSubtree(downProjection, WIREFRAME_NODE_IDS.forEach, upSlot!, down)
+ );
+
+ expect(connectivityKey(restored)).toBe(connectivityKey(base));
+ });
+
+ it('no-ops when the target slot is inside the moved container’s own subtree', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const innerSlot = projection.connectors.find(
+ (c) => c.kind === 'branch-entry' && c.sourceRowId === WIREFRAME_NODE_IDS.ifNode
+ )?.slot;
+ expect(innerSlot?.containerId).toBe(WIREFRAME_NODE_IDS.forEach);
+
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.forEach, innerSlot!, fixture);
+ expect(changeSet).toEqual({
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ });
+ });
+
+ it('no-ops when the target references the moved node directly', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const selfSlot: InsertionSlot = {
+ id: 'probe',
+ source: { nodeId: WIREFRAME_NODE_IDS.forEach },
+ };
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.forEach, selfSlot, fixture);
+ expect(changeSet).toEqual({
+ addNodes: [],
+ addEdges: [],
+ removeNodeIds: [],
+ removeEdgeIds: [],
+ });
+ });
+
+ it('outdents a step out of a container body to just after the container', () => {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const fixture = makeContainerChainFixture();
+ fixture.nodes = fixture.nodes.map((node) => {
+ if (node.id === ids.container) return { ...node, position: { x: 100, y: 200 } };
+ if (node.id === ids.y) {
+ return {
+ ...node,
+ position: { x: 25, y: 75 },
+ extent: 'parent' as const,
+ expandParent: true,
+ };
+ }
+ return node;
+ });
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findOutdentSlot(projection, ids.y);
+ expect(slot).toBeDefined();
+
+ const changeSet = moveSubtree(projection, ids.y, slot!, fixture);
+ expect(changeSet.removeNodeIds).toEqual([ids.y]);
+ expect(changeSet.addNodes[0]).toMatchObject({ position: { x: 125, y: 275 } });
+ expect(changeSet.addNodes[0]?.parentId).toBeUndefined();
+ expect(changeSet.addNodes[0]?.extent).toBeUndefined();
+ expect(changeSet.addNodes[0]?.expandParent).toBeUndefined();
+
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ const order = movedProjection.rows.map((r) => r.nodeId);
+ expect(order.indexOf(ids.container)).toBeLessThan(order.indexOf(ids.y));
+ expect(order.indexOf(ids.y)).toBeLessThan(order.indexOf(ids.b));
+ expect(moved.nodes.find((n) => n.id === ids.x)?.parentId).toBe(ids.container);
+ });
+
+ it('relocates a bare branch owner WITH its lane content across a container boundary', () => {
+ const ids = CROSS_CONTAINER_BRANCH_NODE_IDS;
+ const fixture = makeCrossContainerBranchFixture();
+ fixture.nodes = fixture.nodes.map((node) => {
+ if (node.id === ids.c1) return { ...node, position: { x: 100, y: 200 } };
+ if (node.id === ids.c2) return { ...node, position: { x: 500, y: 700 } };
+ if (node.id === ids.ifNode) return { ...node, expandParent: true };
+ return node;
+ });
+ const absoluteBefore = new Map(
+ [ids.ifNode, ids.thenLeaf, ids.elseLeaf].map((id) => {
+ const node = fixture.nodes.find((candidate) => candidate.id === id)!;
+ return [id, absolutePosition(node, fixture.nodes)] as const;
+ })
+ );
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ // Append after C2's Filler: a source-only slot, so the splice only ADDS
+ // an incoming edge to `If` and never a competing outgoing one.
+ const appendAfterFiller: InsertionSlot = {
+ id: 'probe',
+ source: { nodeId: ids.filler, handleId: 'output' },
+ containerId: ids.c2,
+ };
+
+ const changeSet = moveSubtree(projection, ids.ifNode, appendAfterFiller, fixture);
+ expect(new Set(changeSet.removeNodeIds)).toEqual(
+ new Set([ids.ifNode, ids.thenLeaf, ids.elseLeaf])
+ );
+ const moved = applyChangeSet(fixture, changeSet);
+ for (const node of changeSet.addNodes) {
+ expect(node.parentId).toBe(ids.c2);
+ expect(node.extent).toBe('parent');
+ expect(absolutePosition(node, moved.nodes)).toEqual(absoluteBefore.get(node.id));
+ }
+ expect(changeSet.addNodes.find((node) => node.id === ids.ifNode)?.expandParent).toBe(true);
+
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ expect(moved.nodes.find((n) => n.id === ids.thenLeaf)?.parentId).toBe(ids.c2);
+ expect(moved.nodes.find((n) => n.id === ids.elseLeaf)?.parentId).toBe(ids.c2);
+ const c1Row = movedProjection.rows.find((r) => r.nodeId === ids.c1);
+ expect(c1Row?.collapsible).toBe(false); // c1's body is now empty
+ });
+
+ it('moveStep delegates to moveSubtree for a collapsible node, producing an identical result', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findMoveUpSlot(projection, WIREFRAME_NODE_IDS.forEach)!;
+ const viaMoveStep = moveStep(projection, WIREFRAME_NODE_IDS.forEach, slot, fixture);
+ const viaMoveSubtree = moveSubtree(projection, WIREFRAME_NODE_IDS.forEach, slot, fixture);
+ expect(viaMoveStep).toEqual(viaMoveSubtree);
+ });
+ });
+
+ describe('property: insertAtSlot then removeStep is a topology identity', () => {
+ for (const [name, factory] of [
+ ['wireframe', makeWireframeFixture],
+ ['diamond', makeDiamondFixture],
+ ] as const) {
+ it(`${name}: every step/merge-back slot round-trips`, () => {
+ const base = factory();
+ const baseProjection = projectSequence(base.nodes, base.edges);
+ // Includes 'merge-back': the wireframe's For Each -> Send Message
+ // container-continuation carries a real slot (defect 5), so it must
+ // round-trip identically to a plain 'step' slot. Diamond's own
+ // merge-backs (b->d, c->d) carry no slot and are excluded by the
+ // `c.slot` check regardless, unaffected by widening past 'step'.
+ const stepSlots = baseProjection.connectors
+ .filter((c) => c.kind !== 'branch-entry' && c.kind !== 'goto' && c.slot)
+ .map((c) => c.slot as InsertionSlot);
+ expect(stepSlots.length).toBeGreaterThan(0);
+
+ for (const slot of stepSlots) {
+ const inserted = applyChangeSet(
+ base,
+ insertAtSlot(baseProjection, slot, makeNode('probe'))
+ );
+ const insertedProjection = projectSequence(inserted.nodes, inserted.edges);
+ const healed = applyChangeSet(inserted, removeStep(insertedProjection, 'probe'));
+ expect(topologyKey(healed), `slot ${slot.id}`).toBe(topologyKey(base));
+ }
+ });
+ }
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/mutations.ts b/packages/apollo-react/src/canvas/utils/sequential/mutations.ts
new file mode 100644
index 000000000..eeafb0336
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/mutations.ts
@@ -0,0 +1,628 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { SEQ_CONTINUATION_EDGE_KEY } from './graph-helpers';
+import type {
+ GraphChangeSet,
+ InsertionSlot,
+ SequenceConnector,
+ SequenceProjection,
+} from './sequential.types';
+
+/**
+ * Pure mutation ops as the internal semantic core (D10). Each returns a
+ * {@link GraphChangeSet} the caller applies through the standard onNodesChange /
+ * onEdgesChange callbacks; there is no parallel mutation channel.
+ *
+ * Edge ids follow the AddNodeManager convention
+ * (`edge_${source}-${sourceHandle}-${target}-${targetHandle}`, AddNodePanel.tsx)
+ * so inserts made here are indistinguishable from pipeline inserts. `insertAtSlot`
+ * and `moveStep` read only the projection's connectors (which carry the split
+ * edge id + endpoints on their slot), so they stay pure and never touch xyflow
+ * state. `removeStep` does too UNLESS the node being removed is a container, in
+ * which case the projection's connectors are not enough to cascade correctly
+ * (see its own doc comment) and it accepts the raw graph as an optional third
+ * argument.
+ */
+
+function makeEdge(
+ source: string,
+ sourceHandle: string | undefined,
+ target: string,
+ targetHandle: string | undefined,
+ continuation = false
+): Edge {
+ return {
+ id: `edge_${source}-${sourceHandle ?? ''}-${target}-${targetHandle ?? ''}`,
+ source,
+ target,
+ sourceHandle: sourceHandle ?? undefined,
+ targetHandle: targetHandle ?? undefined,
+ type: 'default',
+ ...(continuation ? { data: { [SEQ_CONTINUATION_EDGE_KEY]: true } } : {}),
+ };
+}
+
+interface Incomer {
+ source: string;
+ sourceHandle?: string;
+ edgeId: string;
+ continuation?: boolean;
+}
+interface Outgoer {
+ target: string;
+ targetHandle?: string;
+ edgeId: string;
+}
+
+/**
+ * Reconstructs a node's real incoming/outgoing sequence edges from the
+ * projection's slot-bearing connectors (`step` / `branch-entry`). merge-back and
+ * goto connectors carry no slot, so degenerate joins are intentionally ignored.
+ */
+function collectSeam(
+ projection: SequenceProjection,
+ nodeId: string
+): { incomers: Incomer[]; outgoers: Outgoer[] } {
+ const incomers: Incomer[] = [];
+ const outgoers: Outgoer[] = [];
+ for (const connector of projection.connectors) {
+ const edgeId = connector.slot?.graphEdgeId;
+ if (!edgeId) continue;
+ if (connector.targetRowId === nodeId) {
+ incomers.push({
+ source: connector.slot?.source?.nodeId ?? connector.sourceRowId,
+ sourceHandle: connector.slot?.source?.handleId,
+ edgeId,
+ continuation: connector.slot?.continuation,
+ });
+ }
+ if (connector.sourceRowId === nodeId) {
+ outgoers.push({
+ target: connector.slot?.target?.nodeId ?? connector.targetRowId,
+ targetHandle: connector.slot?.target?.handleId,
+ edgeId,
+ });
+ }
+ }
+ return { incomers, outgoers };
+}
+
+/** Splice a new node into the graph at the given insertion slot. */
+export function insertAtSlot(
+ _projection: SequenceProjection,
+ slot: InsertionSlot,
+ newNode: Node
+): GraphChangeSet {
+ const node = slot.containerId ? { ...newNode, parentId: slot.containerId } : newNode;
+ const addEdges: Edge[] = [];
+
+ if (slot.graphEdgeId && slot.source && slot.target) {
+ // Splitting an existing edge: it is removed and re-formed around the node.
+ addEdges.push(
+ makeEdge(slot.source.nodeId, slot.source.handleId, node.id, undefined, slot.continuation)
+ );
+ addEdges.push(makeEdge(node.id, undefined, slot.target.nodeId, slot.target.handleId, true));
+ return { addNodes: [node], addEdges, removeNodeIds: [], removeEdgeIds: [slot.graphEdgeId] };
+ }
+ if (slot.source) {
+ addEdges.push(
+ makeEdge(slot.source.nodeId, slot.source.handleId, node.id, undefined, slot.continuation)
+ );
+ } else if (slot.target) {
+ addEdges.push(makeEdge(node.id, undefined, slot.target.nodeId, slot.target.handleId, true));
+ }
+ return { addNodes: [node], addEdges, removeNodeIds: [], removeEdgeIds: [] };
+}
+
+function healEdges(incomers: Incomer[], outgoers: Outgoer[]): Edge[] {
+ const addEdges: Edge[] = [];
+ const seen = new Set();
+ for (const incomer of incomers) {
+ for (const outgoer of outgoers) {
+ // Removing a branch that closes back to its owning loop/container can
+ // put the same owner on both sides of the seam. Healing that boundary
+ // would create a meaningless owner -> owner self-loop; the correct
+ // result is an empty body that remains insertable through projection.
+ if (incomer.source === outgoer.target) continue;
+ const edge = makeEdge(
+ incomer.source,
+ incomer.sourceHandle,
+ outgoer.target,
+ outgoer.targetHandle,
+ incomer.continuation
+ );
+ if (!seen.has(edge.id)) {
+ seen.add(edge.id);
+ addEdges.push(edge);
+ }
+ }
+ }
+ return addEdges;
+}
+
+/**
+ * Any connector with a slot whose target is `nodeId`: its true incoming seam
+ * edge, regardless of kind. `branch-entry` needs no exclusion here (unlike
+ * {@link ownOutgoingConnector}) because a node can never be the target of its
+ * OWN branch-entry connector (projectSequence always sources those at the
+ * owner, see below).
+ */
+export function ownIncomingConnector(
+ projection: SequenceProjection,
+ nodeId: string
+): SequenceConnector | undefined {
+ return projection.connectors.find((c) => c.targetRowId === nodeId && c.slot);
+}
+
+/**
+ * The connector representing `nodeId`'s own genuine next step, excluding
+ * `branch-entry`: projectSequence's walkContainerBody/walkBranch always emit a
+ * `branch-entry` connector SOURCED AT the container/branch owner into its own
+ * body/lane, never a real forward step to a sibling or the enclosing scope.
+ * Plain `collectSeam` (used by leaf-only `moveStep`) does not apply this
+ * exclusion, which is exactly why a container/branch owner needs
+ * {@link collectOwnSeam} instead - see `moveSubtree`'s doc comment.
+ */
+export function ownOutgoingConnector(
+ projection: SequenceProjection,
+ nodeId: string
+): SequenceConnector | undefined {
+ return projection.connectors.find(
+ (c) => c.sourceRowId === nodeId && c.kind !== 'branch-entry' && c.slot
+ );
+}
+
+/**
+ * Like {@link collectSeam}, but safe for a node that may itself own a
+ * body/branch (a container, or a branch source): see {@link ownOutgoingConnector}.
+ */
+function collectOwnSeam(
+ projection: SequenceProjection,
+ nodeId: string
+): { incomers: Incomer[]; outgoers: Outgoer[] } {
+ const incomers: Incomer[] = [];
+ const outgoers: Outgoer[] = [];
+ for (const connector of projection.connectors) {
+ const edgeId = connector.slot?.graphEdgeId;
+ if (!edgeId) continue;
+ if (connector.targetRowId === nodeId) {
+ incomers.push({
+ source: connector.slot?.source?.nodeId ?? connector.sourceRowId,
+ sourceHandle: connector.slot?.source?.handleId,
+ edgeId,
+ continuation: connector.slot?.continuation,
+ });
+ }
+ if (connector.sourceRowId === nodeId && connector.kind !== 'branch-entry') {
+ outgoers.push({
+ target: connector.slot?.target?.nodeId ?? connector.targetRowId,
+ targetHandle: connector.slot?.target?.handleId,
+ edgeId,
+ });
+ }
+ }
+ return { incomers, outgoers };
+}
+
+/**
+ * Every node's id transitively owned by `rootId` in the PROJECTED tree (via
+ * `SequenceRow.parentRowId`, which covers both container nesting AND branch
+ * ownership - see graph-helpers.ts's dual nesting model), as opposed to
+ * {@link collectDescendantIds}'s pure `node.parentId` walk. A branch owner's
+ * lane content shares the owner's OWN `parentId` (branches never reparent),
+ * so this is the set `moveSubtree` must consider for a possible `parentId`
+ * rewrite when the owner itself crosses a container boundary.
+ */
+function collectProjectedDescendantIds(projection: SequenceProjection, rootId: string): string[] {
+ const childRowIds = new Map();
+ for (const row of projection.rows) {
+ if (row.parentRowId === undefined || row.lanePlaceholder) continue;
+ const bucket = childRowIds.get(row.parentRowId);
+ if (bucket) bucket.push(row.nodeId);
+ else childRowIds.set(row.parentRowId, [row.nodeId]);
+ }
+ const result: string[] = [];
+ const stack = [...(childRowIds.get(rootId) ?? [])];
+ while (stack.length > 0) {
+ const id = stack.pop() as string;
+ result.push(id);
+ stack.push(...(childRowIds.get(id) ?? []));
+ }
+ return result;
+}
+
+/** Resolves a node's flow-view position into the root coordinate space. */
+function absolutePosition(
+ node: Node,
+ nodesById: ReadonlyMap
+): { x: number; y: number } {
+ let x = node.position.x;
+ let y = node.position.y;
+ let parentId = node.parentId;
+ const visited = new Set([node.id]);
+
+ while (parentId && !visited.has(parentId)) {
+ visited.add(parentId);
+ const parent = nodesById.get(parentId);
+ if (!parent) break;
+ x += parent.position.x;
+ y += parent.position.y;
+ parentId = parent.parentId;
+ }
+
+ return { x, y };
+}
+
+/**
+ * Reparents a node without changing its flow-view absolute position. React Flow
+ * stores a child position relative to its parent, so crossing a container
+ * boundary requires a coordinate conversion as well as a `parentId` rewrite.
+ * Parent-only containment flags are added/removed with the relationship so an
+ * outdented node never retains an invalid `extent: 'parent'`/`expandParent`.
+ */
+function reparentNode(
+ node: Node,
+ parentId: string | undefined,
+ nodesById: ReadonlyMap
+): Node {
+ const absolute = absolutePosition(node, nodesById);
+ const parent = parentId ? nodesById.get(parentId) : undefined;
+ const parentAbsolute = parent ? absolutePosition(parent, nodesById) : { x: 0, y: 0 };
+ const next: Node = {
+ ...node,
+ position: {
+ x: absolute.x - parentAbsolute.x,
+ y: absolute.y - parentAbsolute.y,
+ },
+ };
+
+ if (parentId === undefined) {
+ delete next.parentId;
+ if (next.extent === 'parent') delete next.extent;
+ delete next.expandParent;
+ } else {
+ next.parentId = parentId;
+ next.extent = 'parent';
+ }
+ return next;
+}
+
+/** Every node's id transitively nested under `containerId` via `node.parentId`. */
+function collectDescendantIds(nodes: Node[], containerId: string): string[] {
+ const childrenByParent = new Map();
+ for (const node of nodes) {
+ if (!node.parentId) continue;
+ const bucket = childrenByParent.get(node.parentId);
+ if (bucket) bucket.push(node.id);
+ else childrenByParent.set(node.parentId, [node.id]);
+ }
+ const result: string[] = [];
+ const stack = [...(childrenByParent.get(containerId) ?? [])];
+ while (stack.length > 0) {
+ const id = stack.pop() as string;
+ result.push(id);
+ stack.push(...(childrenByParent.get(id) ?? []));
+ }
+ return result;
+}
+
+/**
+ * Remove a step and heal the seam so the sequence stays connected.
+ *
+ * The projection's connectors alone are not enough to remove a CONTAINER
+ * correctly: they omit the container's descendants entirely, and they never
+ * carry a raw edge that `forwardOut` filtered out of the forward walk (e.g. a
+ * loop body's container-closing "continue" edge) — deleting only the
+ * container would leave descendants with a `parentId` pointing at nothing and
+ * strand those edges. Passing the optional `graph` (the canonical raw
+ * nodes/edges the host already holds) lets `removeStep` cascade correctly:
+ * every descendant is removed, every raw edge with an endpoint inside
+ * `{nodeId} ∪ descendants` is removed, and only the container's OWN
+ * incomer/outgoer seam — never a body-internal edge — is healed. Without
+ * `graph`, the op keeps its original single-node behavior (backward
+ * compatible with existing 2-argument callers), so callers must not point it
+ * at a container unless they supply `graph`.
+ */
+export function removeStep(
+ projection: SequenceProjection,
+ nodeId: string,
+ graph?: { nodes: Node[]; edges: Edge[] }
+): GraphChangeSet {
+ if (!graph) {
+ const { incomers, outgoers } = collectSeam(projection, nodeId);
+ const removeEdgeIds = [
+ ...new Set([...incomers.map((i) => i.edgeId), ...outgoers.map((o) => o.edgeId)]),
+ ];
+ return {
+ addNodes: [],
+ addEdges: healEdges(incomers, outgoers),
+ removeNodeIds: [nodeId],
+ removeEdgeIds,
+ };
+ }
+
+ const structuralDescendants = collectDescendantIds(graph.nodes, nodeId);
+ const projectedDescendants = collectProjectedDescendantIds(projection, nodeId);
+ const removeSet = new Set([nodeId, ...structuralDescendants, ...projectedDescendants]);
+ const removesProjectedBranch = projectedDescendants.some(
+ (id) => !structuralDescendants.includes(id)
+ );
+ const incomers: Incomer[] = [];
+ const outgoers: Outgoer[] = [];
+ const removeEdgeIds = new Set();
+ for (const edge of graph.edges) {
+ const sourceIn = removeSet.has(edge.source);
+ const targetIn = removeSet.has(edge.target);
+ if (!sourceIn && !targetIn) continue;
+ removeEdgeIds.add(edge.id);
+ // Containers heal only their own outer seam. Branch owners also remove
+ // their projected lane rows, so the lane-tail -> merge boundary is their
+ // semantic outgoing seam and must be healed too.
+ if (!sourceIn && targetIn && (edge.target === nodeId || removesProjectedBranch)) {
+ incomers.push({
+ source: edge.source,
+ sourceHandle: edge.sourceHandle ?? undefined,
+ edgeId: edge.id,
+ });
+ }
+ if (sourceIn && !targetIn && (edge.source === nodeId || removesProjectedBranch)) {
+ outgoers.push({
+ target: edge.target,
+ targetHandle: edge.targetHandle ?? undefined,
+ edgeId: edge.id,
+ });
+ }
+ }
+
+ return {
+ addNodes: [],
+ addEdges: healEdges(incomers, outgoers),
+ removeNodeIds: [...removeSet],
+ removeEdgeIds: [...removeEdgeIds],
+ };
+}
+
+/**
+ * True when `containerId` is `ancestorId` itself or is structurally nested
+ * inside it, walking the projection's `parentRowId` chain (container/branch
+ * ownership; see the module-level moveStep guard). Reused by `moveSubtree`'s
+ * self-subtree guard for THREE different candidates (a target slot's
+ * `containerId`, `source.nodeId`, and `target.nodeId`), since any of them
+ * landing inside the moved node's own subtree is equally a degenerate move.
+ */
+function isNestedContainer(
+ projection: SequenceProjection,
+ containerId: string,
+ ancestorId: string
+): boolean {
+ if (containerId === ancestorId) return true;
+ const rowsById = new Map(projection.rows.map((row) => [row.nodeId, row]));
+ const seen = new Set();
+ let current = rowsById.get(containerId)?.parentRowId;
+ while (current !== undefined && !seen.has(current)) {
+ if (current === ancestorId) return true;
+ seen.add(current);
+ current = rowsById.get(current)?.parentRowId;
+ }
+ return false;
+}
+
+/**
+ * Move a step to a different slot (unlocks kebab "Move up/down" in v1.5). Heals
+ * the old seam and splices the node into the target slot by rewiring edges only.
+ * The node object is not re-created, so a cross-container move does not update
+ * `parentId` here; v1.5 reorder stays within a lane where `parentId` is stable.
+ *
+ * Delegates entirely to {@link moveSubtree} whenever `nodeId`'s row is
+ * `collapsible` (a container or branch owner, per `SequenceRow.collapsible`),
+ * `graph` included: plain `collectSeam` would otherwise misidentify that
+ * node's OWN `branch-entry` connector (its own body/branch entry) as a real
+ * external outgoer and corrupt the container/branch on splice - see
+ * `moveSubtree`'s doc comment. This delegation does not depend on whether
+ * `graph` is supplied: the seam fix is needed either way, `graph` only
+ * additionally lets a cross-container move correct its parent-relative
+ * position and containment. For leaf nodes the two ops are equivalent (no
+ * `branch-entry` connector is ever sourced at a leaf), so this changes nothing
+ * for existing leaf-only callers/tests.
+ *
+ * The VIEW layer's four tree operations (move up/down, indent, outdent)
+ * should call `moveSubtree` directly with `graph`, not this function: even a
+ * plain leaf crosses a container boundary on indent/outdent, and only
+ * `moveSubtree` updates its parent-relative geometry and containment.
+ */
+export function moveStep(
+ projection: SequenceProjection,
+ nodeId: string,
+ to: InsertionSlot,
+ graph?: { nodes: Node[]; edges: Edge[] }
+): GraphChangeSet {
+ const row = projection.rows.find((r) => r.nodeId === nodeId);
+ if (row?.collapsible) {
+ return moveSubtree(projection, nodeId, to, graph);
+ }
+
+ const { incomers, outgoers } = collectSeam(projection, nodeId);
+ const ownSeamEdgeIds = new Set([
+ ...incomers.map((i) => i.edgeId),
+ ...outgoers.map((o) => o.edgeId),
+ ]);
+
+ // A degenerate "move to where it already is" (e.g. Move Up at the top of a
+ // lane targets the node's own incoming edge), or a container moved into a
+ // slot inside its own subtree, would splice a self-loop and orphan the
+ // node's real neighbor. No-op instead of corrupting the graph.
+ const targetsSelf =
+ to.source?.nodeId === nodeId ||
+ to.target?.nodeId === nodeId ||
+ (to.graphEdgeId !== undefined && ownSeamEdgeIds.has(to.graphEdgeId)) ||
+ (to.containerId !== undefined && isNestedContainer(projection, to.containerId, nodeId));
+ if (targetsSelf) {
+ return { addNodes: [], addEdges: [], removeNodeIds: [], removeEdgeIds: [] };
+ }
+
+ const removeEdgeIds = new Set(ownSeamEdgeIds);
+ if (to.graphEdgeId) removeEdgeIds.add(to.graphEdgeId);
+
+ const addEdges: Edge[] = [];
+ const seen = new Set();
+ const pushEdge = (edge: Edge): void => {
+ if (!seen.has(edge.id)) {
+ seen.add(edge.id);
+ addEdges.push(edge);
+ }
+ };
+
+ for (const incomer of incomers) {
+ for (const outgoer of outgoers) {
+ if (incomer.source === nodeId || outgoer.target === nodeId) continue;
+ pushEdge(
+ makeEdge(
+ incomer.source,
+ incomer.sourceHandle,
+ outgoer.target,
+ outgoer.targetHandle,
+ incomer.continuation
+ )
+ );
+ }
+ }
+
+ if (to.source && to.target && to.graphEdgeId) {
+ pushEdge(makeEdge(to.source.nodeId, to.source.handleId, nodeId, undefined, to.continuation));
+ pushEdge(makeEdge(nodeId, undefined, to.target.nodeId, to.target.handleId, true));
+ } else if (to.source) {
+ pushEdge(makeEdge(to.source.nodeId, to.source.handleId, nodeId, undefined, to.continuation));
+ } else if (to.target) {
+ pushEdge(makeEdge(nodeId, undefined, to.target.nodeId, to.target.handleId, true));
+ }
+
+ return { addNodes: [], addEdges, removeNodeIds: [], removeEdgeIds: [...removeEdgeIds] };
+}
+
+/**
+ * Move a container or branch owner WITH its entire subtree as a single unit
+ * (file-explorer-like tree move; the four view-layer operations - move
+ * up/down, indent, outdent - should all call this directly, with `graph`).
+ *
+ * No cascade is needed for the subtree's CONTENT, unlike `removeStep`: every
+ * descendant is reached purely through edges this op never touches. A
+ * container's children keep pointing at `nodeId` by id via `parentId`, which
+ * stays valid regardless of where the container node itself relocates. A
+ * branch owner's lanes stay wired via its own untouched forward edges (e.g.
+ * `if.true -> thenNode`), which simply follow `nodeId` wherever it moves. The
+ * op only has to get `nodeId`'s OWN seam right, which is exactly what
+ * {@link collectOwnSeam} does (excluding its own `branch-entry` connectors,
+ * unlike plain `collectSeam`) - see `moveStep`'s doc comment for why this
+ * matters.
+ *
+ * ONE thing edges alone cannot carry: a branch owner's lane content shares
+ * the OWNER's OWN `parentId` (branches never reparent - see graph-helpers.ts's
+ * dual nesting model), so if `nodeId` itself crosses a container boundary,
+ * any descendant that shared `nodeId`'s OLD parent must move to the NEW parent
+ * too, or it is silently left behind. Reparenting also converts each moved
+ * node's absolute position into the destination parent's coordinate space and
+ * synchronizes `extent`/`expandParent`, keeping the flow view stable and valid.
+ * A genuine container-child of `nodeId` (`parentId === nodeId`) needs no such
+ * rewrite: it follows the unchanged owner id and retains its local position.
+ * These rewrites require the raw `graph` (a `Node`'s full shape cannot be
+ * reconstructed from the projection alone); without it, containment and
+ * geometry are left untouched, matching `moveStep`'s documented limitation.
+ *
+ * KNOWN LIMITATION: a container idiom where a body's tail closes the loop via
+ * an edge back into the container itself (e.g. For Each's `continue` handle)
+ * is invisible to this layer - `forwardOut` filters it out of every walk (see
+ * `removeStep`'s doc comment), so it is never part of any seam this op
+ * computes, and is left untouched pointing at whichever node was the body's
+ * tail BEFORE the move. Callers whose container manifests use that
+ * convention are responsible for relocating such an edge themselves after an
+ * indent/outdent that changes which node is the body's last step.
+ */
+export function moveSubtree(
+ projection: SequenceProjection,
+ nodeId: string,
+ to: InsertionSlot,
+ graph?: { nodes: Node[]; edges: Edge[] }
+): GraphChangeSet {
+ const { incomers, outgoers } = collectOwnSeam(projection, nodeId);
+ const ownSeamEdgeIds = new Set([
+ ...incomers.map((i) => i.edgeId),
+ ...outgoers.map((o) => o.edgeId),
+ ]);
+
+ // Extends moveStep's self-loop guard: a target referencing `nodeId` itself,
+ // one of its own seam edges, or ANY location inside its own projected
+ // subtree (container OR branch-lane content - `isNestedContainer` walks
+ // `parentRowId`, which covers both) would splice `nodeId` into its own
+ // body. No-op instead of corrupting the graph.
+ const targetsSelfOrOwnSubtree =
+ to.source?.nodeId === nodeId ||
+ to.target?.nodeId === nodeId ||
+ (to.graphEdgeId !== undefined && ownSeamEdgeIds.has(to.graphEdgeId)) ||
+ (to.containerId !== undefined && isNestedContainer(projection, to.containerId, nodeId)) ||
+ (to.source !== undefined && isNestedContainer(projection, to.source.nodeId, nodeId)) ||
+ (to.target !== undefined && isNestedContainer(projection, to.target.nodeId, nodeId));
+ if (targetsSelfOrOwnSubtree) {
+ return { addNodes: [], addEdges: [], removeNodeIds: [], removeEdgeIds: [] };
+ }
+
+ const removeEdgeIds = new Set(ownSeamEdgeIds);
+ if (to.graphEdgeId) removeEdgeIds.add(to.graphEdgeId);
+
+ const addEdges: Edge[] = [];
+ const seen = new Set();
+ const pushEdge = (edge: Edge): void => {
+ if (!seen.has(edge.id)) {
+ seen.add(edge.id);
+ addEdges.push(edge);
+ }
+ };
+
+ for (const incomer of incomers) {
+ for (const outgoer of outgoers) {
+ if (incomer.source === nodeId || outgoer.target === nodeId) continue;
+ pushEdge(
+ makeEdge(
+ incomer.source,
+ incomer.sourceHandle,
+ outgoer.target,
+ outgoer.targetHandle,
+ incomer.continuation
+ )
+ );
+ }
+ }
+
+ if (to.source && to.target && to.graphEdgeId) {
+ pushEdge(makeEdge(to.source.nodeId, to.source.handleId, nodeId, undefined, to.continuation));
+ pushEdge(makeEdge(nodeId, undefined, to.target.nodeId, to.target.handleId, true));
+ } else if (to.source) {
+ pushEdge(makeEdge(to.source.nodeId, to.source.handleId, nodeId, undefined, to.continuation));
+ } else if (to.target) {
+ pushEdge(makeEdge(nodeId, undefined, to.target.nodeId, to.target.handleId, true));
+ }
+
+ const removeNodeIds: string[] = [];
+ const addNodes: Node[] = [];
+ if (graph) {
+ const nodesById = new Map(graph.nodes.map((n) => [n.id, n]));
+ const rawNode = nodesById.get(nodeId);
+ if (rawNode && rawNode.parentId !== to.containerId) {
+ const oldParentId = rawNode.parentId;
+ removeNodeIds.push(nodeId);
+ addNodes.push(reparentNode(rawNode, to.containerId, nodesById));
+
+ // Branch-lane content shares nodeId's OLD parentId (it has none of its
+ // own distinct from its owner); it must travel with nodeId. A genuine
+ // container-child of nodeId (parentId === nodeId, untouched here) is
+ // already correctly nested regardless of where nodeId now lives.
+ for (const descendantId of collectProjectedDescendantIds(projection, nodeId)) {
+ const rawDescendant = nodesById.get(descendantId);
+ if (rawDescendant && rawDescendant.parentId === oldParentId) {
+ removeNodeIds.push(descendantId);
+ addNodes.push(reparentNode(rawDescendant, to.containerId, nodesById));
+ }
+ }
+ }
+ }
+
+ return { addNodes, addEdges, removeNodeIds, removeEdgeIds: [...removeEdgeIds] };
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/previewRow.test.ts b/packages/apollo-react/src/canvas/utils/sequential/previewRow.test.ts
new file mode 100644
index 000000000..a3e9fe0a5
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/previewRow.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from 'vitest';
+import { PREVIEW_NODE_ID, SEQ_BAR_HEIGHT, SEQ_ROW_GAP } from '../../constants';
+import { layoutSequence } from './layoutSequence';
+import { projectionWithPreviewRow } from './previewRow';
+import type { SequenceConnector, SequenceProjection, SequenceRow } from './sequential.types';
+
+const PITCH = SEQ_BAR_HEIGHT + SEQ_ROW_GAP;
+
+function row(nodeId: string, depth: number, parentRowId?: string): SequenceRow {
+ return { nodeId, depth, parentRowId, collapsible: false, collapsed: false, visible: true };
+}
+
+function projection(rows: SequenceRow[], connectors: SequenceConnector[] = []): SequenceProjection {
+ return { rows, connectors, slots: connectors.flatMap((connector) => connector.slot ?? []) };
+}
+
+function ids(proj: SequenceProjection): string[] {
+ return proj.rows.map((r) => r.nodeId);
+}
+
+function previewRow(proj: SequenceProjection): SequenceRow | undefined {
+ return proj.rows.find((r) => r.nodeId === PREVIEW_NODE_ID);
+}
+
+describe('projectionWithPreviewRow', () => {
+ it('splices the preview row before a splice target, at the target depth', () => {
+ const base = projection([row('a', 0), row('b', 1, 'a'), row('c', 0)]);
+ const next = projectionWithPreviewRow(base, { id: 's', target: { nodeId: 'b' } });
+
+ expect(ids(next)).toEqual(['a', PREVIEW_NODE_ID, 'b', 'c']);
+ expect(previewRow(next)).toMatchObject({ depth: 1, parentRowId: 'a' });
+ // base is untouched (pure).
+ expect(ids(base)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('appends a sibling/leaf after the source subtree, at the source depth', () => {
+ // b (depth 0) owns a subtree [b-child]; appending after b lands past it.
+ const base = projection([row('a', 0), row('b', 0), row('b-child', 1, 'b'), row('c', 0)]);
+ const next = projectionWithPreviewRow(base, { id: 's', source: { nodeId: 'b' } });
+
+ expect(ids(next)).toEqual(['a', 'b', 'b-child', PREVIEW_NODE_ID, 'c']);
+ expect(previewRow(next)).toMatchObject({ depth: 0, parentRowId: undefined });
+ });
+
+ it('appends as the first child of an empty container (one level deeper)', () => {
+ const base = projection([row('a', 0), row('k', 0), row('c', 0)]);
+ const next = projectionWithPreviewRow(base, {
+ id: 's',
+ source: { nodeId: 'k' },
+ containerId: 'k',
+ });
+
+ expect(ids(next)).toEqual(['a', 'k', PREVIEW_NODE_ID, 'c']);
+ expect(previewRow(next)).toMatchObject({ depth: 1, parentRowId: 'k' });
+ });
+
+ it('appends at the very end for a tail append', () => {
+ const base = projection([row('a', 0), row('b', 0)]);
+ const next = projectionWithPreviewRow(base, { id: 's', source: { nodeId: 'b' } });
+
+ expect(ids(next)).toEqual(['a', 'b', PREVIEW_NODE_ID]);
+ });
+
+ it('swaps an empty lane placeholder for the preview in place', () => {
+ // A lane placeholder occupies the lane's slot; inserting into it must keep
+ // that depth/parent, not run the generic append (which would land at the
+ // parent's depth).
+ const laneRow: SequenceRow = {
+ nodeId: '__sequential-lane__if::true',
+ depth: 1,
+ parentRowId: 'if',
+ collapsible: false,
+ collapsed: false,
+ visible: true,
+ branch: { sourceNodeId: 'if', handleId: 'true', label: 'Then' },
+ lanePlaceholder: { id: 'slot:lane:if:true', source: { nodeId: 'if', handleId: 'true' } },
+ };
+ const base = projection([row('if', 0), laneRow, row('c', 0)]);
+ const next = projectionWithPreviewRow(base, { id: 'slot:lane:if:true' });
+
+ // The placeholder is replaced (not added) by the preview, at its depth/parent.
+ expect(ids(next)).toEqual(['if', PREVIEW_NODE_ID, 'c']);
+ expect(previewRow(next)).toMatchObject({
+ depth: 1,
+ parentRowId: 'if',
+ branch: { handleId: 'true', label: 'Then' },
+ });
+ });
+
+ it('returns the projection unchanged when the anchor row is absent', () => {
+ const base = projection([row('a', 0)]);
+ expect(projectionWithPreviewRow(base, { id: 's', target: { nodeId: 'missing' } })).toBe(base);
+ expect(projectionWithPreviewRow(base, { id: 's' })).toBe(base);
+ });
+
+ it('opens a one-row gap in the layout: downstream rows shift by one pitch', () => {
+ const base = projection([row('a', 0), row('b', 0), row('c', 0)]);
+ const withPreview = projectionWithPreviewRow(base, { id: 's', target: { nodeId: 'b' } });
+ const layout = layoutSequence(withPreview);
+
+ expect(layout.positions.get('a')?.y).toBe(0);
+ // The preview takes b's original slot; b and c each drop by one pitch.
+ expect(layout.positions.get(PREVIEW_NODE_ID)?.y).toBe(PITCH);
+ expect(layout.positions.get('b')?.y).toBe(2 * PITCH);
+ expect(layout.positions.get('c')?.y).toBe(3 * PITCH);
+ });
+
+ it('replaces a split connector with preview connectors using final row geometry', () => {
+ const slot = {
+ id: 'slot:edge:a-b',
+ source: { nodeId: 'a', handleId: 'output' },
+ target: { nodeId: 'b', handleId: 'input' },
+ graphEdgeId: 'a-b',
+ };
+ const base = projection(
+ [row('a', 0), row('b', 0)],
+ [
+ {
+ id: 'conn:a-b',
+ kind: 'step',
+ sourceRowId: 'a',
+ targetRowId: 'b',
+ slot,
+ },
+ ]
+ );
+
+ const next = projectionWithPreviewRow(base, slot);
+ expect(next.connectors).toEqual([
+ expect.objectContaining({
+ kind: 'step',
+ sourceRowId: 'a',
+ targetRowId: PREVIEW_NODE_ID,
+ }),
+ expect.objectContaining({
+ kind: 'step',
+ sourceRowId: PREVIEW_NODE_ID,
+ targetRowId: 'b',
+ }),
+ ]);
+ expect(next.connectors.some((connector) => connector.id === 'conn:a-b')).toBe(false);
+
+ const previewLayout = layoutSequence(next);
+ const committed = projection(
+ [row('a', 0), row('new', 0), row('b', 0)],
+ next.connectors.map((connector) => ({
+ ...connector,
+ id: connector.id.replace('preview:', 'committed:'),
+ sourceRowId: connector.sourceRowId === PREVIEW_NODE_ID ? 'new' : connector.sourceRowId,
+ targetRowId: connector.targetRowId === PREVIEW_NODE_ID ? 'new' : connector.targetRowId,
+ }))
+ );
+ const committedLayout = layoutSequence(committed);
+
+ expect(previewLayout.positions.get(PREVIEW_NODE_ID)).toEqual(
+ committedLayout.positions.get('new')
+ );
+ });
+
+ it('preserves branch-entry routing when an empty lane placeholder becomes a preview', () => {
+ const slot = { id: 'slot:lane:if:true', source: { nodeId: 'if', handleId: 'true' } };
+ const laneRow: SequenceRow = {
+ ...row('__sequential-lane__if::true', 1, 'if'),
+ branch: { sourceNodeId: 'if', handleId: 'true', label: 'Then' },
+ lanePlaceholder: slot,
+ };
+ const base = projection(
+ [row('if', 0), laneRow],
+ [
+ {
+ id: 'conn:lane:if:true',
+ kind: 'branch-entry',
+ sourceRowId: 'if',
+ targetRowId: laneRow.nodeId,
+ label: 'Then',
+ },
+ ]
+ );
+
+ const next = projectionWithPreviewRow(base, slot);
+ const incoming = next.connectors.find((connector) => connector.targetRowId === PREVIEW_NODE_ID);
+ expect(incoming).toMatchObject({
+ kind: 'branch-entry',
+ sourceRowId: 'if',
+ targetRowId: PREVIEW_NODE_ID,
+ label: 'Then',
+ });
+ expect(layoutSequence(next).connectorWaypoints.get(incoming!.id)).toHaveLength(2);
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/previewRow.ts b/packages/apollo-react/src/canvas/utils/sequential/previewRow.ts
new file mode 100644
index 000000000..87eecd062
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/previewRow.ts
@@ -0,0 +1,162 @@
+import { PREVIEW_NODE_ID } from '../../constants';
+import type {
+ InsertionSlot,
+ SequenceConnector,
+ SequenceProjection,
+ SequenceRow,
+} from './sequential.types';
+
+const previewConnectorId = (slotId: string, side: 'incoming' | 'outgoing'): string =>
+ `preview:${slotId}:${side}`;
+
+function projectionWithPreviewConnectors(
+ projection: SequenceProjection,
+ slot: InsertionSlot,
+ previewRow: SequenceRow,
+ replacedLaneNodeId?: string
+): SequenceConnector[] {
+ const matchedIndex = projection.connectors.findIndex(
+ (connector) =>
+ connector.slot?.id === slot.id ||
+ (replacedLaneNodeId !== undefined && connector.targetRowId === replacedLaneNodeId)
+ );
+ const matched = matchedIndex === -1 ? undefined : projection.connectors[matchedIndex];
+ const connectors = matched
+ ? projection.connectors.filter((_, index) => index !== matchedIndex)
+ : [...projection.connectors];
+
+ const sourceId = slot.source?.nodeId ?? matched?.sourceRowId;
+ const targetId = slot.target?.nodeId ?? matched?.targetRowId;
+
+ if (sourceId) {
+ const sourceRow = projection.rows.find((row) => row.nodeId === sourceId);
+ const ownsNestedRows = projection.rows.some((row) => row.parentRowId === sourceId);
+ const inferredKind =
+ previewRow.depth > (sourceRow?.depth ?? previewRow.depth)
+ ? 'branch-entry'
+ : ownsNestedRows
+ ? 'merge-back'
+ : 'step';
+ connectors.push({
+ id: previewConnectorId(slot.id, 'incoming'),
+ kind: matched?.kind ?? inferredKind,
+ sourceRowId: sourceId,
+ targetRowId: PREVIEW_NODE_ID,
+ label: matched?.label ?? previewRow.branch?.label,
+ slot,
+ });
+ }
+
+ if (targetId && targetId !== replacedLaneNodeId) {
+ connectors.push({
+ id: previewConnectorId(slot.id, 'outgoing'),
+ kind: 'step',
+ sourceRowId: PREVIEW_NODE_ID,
+ targetRowId: targetId,
+ slot,
+ });
+ }
+
+ return connectors;
+}
+
+/**
+ * Returns a copy of `projection` with one synthetic preview row (keyed by
+ * {@link PREVIEW_NODE_ID}) spliced into `rows` at the position the Add Node
+ * insert would land. Feeding this to {@link layoutSequence} while the Add Node
+ * panel is open makes the gap open by exactly one row pitch, positions the
+ * preview bar in its real slot, and recomputes EVERY connector's waypoints from
+ * the shifted geometry -- so branch/merge/goto elbows follow the shift instead
+ * of keeping stale absolute waypoints (the old position-only post-pass left
+ * them behind). Pure: `projection` is never mutated, and the projection memo it
+ * feeds stays untouched (D12) -- only the layout memo re-runs for the insert.
+ *
+ * The row's depth/index mirror where the committed node parents (see
+ * `sequentialOnBeforeNodeAdded`, which parents on `slot.containerId`):
+ * - splice (slot.target): before the target row, at the target's depth;
+ * - append into an empty container (slot.containerId === slot.source): as the
+ * container's first child (one level deeper), right after the container row;
+ * - sibling/leaf append: after the source's whole subtree, at the source depth.
+ * If the slot's anchor row can't be found, the projection is returned unchanged.
+ */
+export function projectionWithPreviewRow(
+ projection: SequenceProjection,
+ slot: InsertionSlot
+): SequenceProjection {
+ const { rows } = projection;
+
+ // Inserting into an empty branch lane: swap that lane's placeholder row for the
+ // preview IN PLACE, so the preview bar keeps the lane's depth, parent, and
+ // mid-left entry (the generic append logic below would mis-place it at the
+ // parent's depth). The stale placeholder node is dropped by the canvas while
+ // this insert is active.
+ const laneIndex = rows.findIndex((row) => row.lanePlaceholder?.id === slot.id);
+ if (laneIndex !== -1) {
+ const laneRow = rows[laneIndex]!;
+ const swapped: SequenceRow = {
+ nodeId: PREVIEW_NODE_ID,
+ depth: laneRow.depth,
+ parentRowId: laneRow.parentRowId,
+ branch: laneRow.branch,
+ collapsible: false,
+ collapsed: false,
+ visible: true,
+ };
+ return {
+ ...projection,
+ rows: [...rows.slice(0, laneIndex), swapped, ...rows.slice(laneIndex + 1)],
+ connectors: projectionWithPreviewConnectors(projection, slot, swapped, laneRow.nodeId),
+ };
+ }
+
+ const targetId = slot.target?.nodeId;
+ const sourceId = slot.source?.nodeId;
+
+ let insertIndex: number;
+ let depth: number;
+ let parentRowId: string | undefined;
+
+ if (targetId) {
+ const targetIndex = rows.findIndex((row) => row.nodeId === targetId);
+ if (targetIndex === -1) return projection;
+ const targetRow = rows[targetIndex]!;
+ insertIndex = targetIndex;
+ depth = targetRow.depth;
+ parentRowId = targetRow.parentRowId;
+ } else if (sourceId) {
+ const sourceIndex = rows.findIndex((row) => row.nodeId === sourceId);
+ if (sourceIndex === -1) return projection;
+ const sourceRow = rows[sourceIndex]!;
+ if (slot.containerId === sourceId) {
+ // Append as the first child of an (empty) container body.
+ depth = sourceRow.depth + 1;
+ parentRowId = sourceId;
+ insertIndex = sourceIndex + 1;
+ } else {
+ // Sibling / leaf append: land after the source's whole subtree (the
+ // contiguous run of strictly-deeper rows immediately following it).
+ depth = sourceRow.depth;
+ parentRowId = sourceRow.parentRowId;
+ let idx = sourceIndex + 1;
+ while (idx < rows.length && rows[idx]!.depth > sourceRow.depth) idx++;
+ insertIndex = idx;
+ }
+ } else {
+ return projection;
+ }
+
+ const previewRow: SequenceRow = {
+ nodeId: PREVIEW_NODE_ID,
+ depth,
+ parentRowId,
+ collapsible: false,
+ collapsed: false,
+ visible: true,
+ };
+
+ return {
+ ...projection,
+ rows: [...rows.slice(0, insertIndex), previewRow, ...rows.slice(insertIndex)],
+ connectors: projectionWithPreviewConnectors(projection, slot, previewRow),
+ };
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/projectSequence.test.ts b/packages/apollo-react/src/canvas/utils/sequential/projectSequence.test.ts
new file mode 100644
index 000000000..5df289d78
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/projectSequence.test.ts
@@ -0,0 +1,635 @@
+import type { Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import {
+ makeBranchCycleFixture,
+ makeBranchVisitedTargetFixture,
+ makeCycleFixture,
+ makeDeepNestingFixture,
+ makeDiamondFixture,
+ makeEmptyBranchFixture,
+ makeInterleavedOrphanFixture,
+ makeLoopBackFixture,
+ makeMultiRootFixture,
+ makeNestedBranchFixture,
+ makeOrphanFixture,
+ makeSingleNodeFixture,
+ makeUnstructuredMergeFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from './fixtures';
+import { SEQ_CONTINUATION_EDGE_KEY } from './graph-helpers';
+import { projectSequence } from './projectSequence';
+import type { SequenceConnector, SequenceProjection, SequenceRow } from './sequential.types';
+
+function rowById(projection: SequenceProjection, nodeId: string): SequenceRow {
+ const row = projection.rows.find((r) => r.nodeId === nodeId);
+ if (!row) throw new Error(`row ${nodeId} not found`);
+ return row;
+}
+
+function connector(
+ projection: SequenceProjection,
+ source: string,
+ target: string
+): SequenceConnector {
+ const found = projection.connectors.find(
+ (c) => c.sourceRowId === source && c.targetRowId === target
+ );
+ if (!found) throw new Error(`connector ${source}->${target} not found`);
+ return found;
+}
+
+describe('projectSequence', () => {
+ describe('wireframe fixture', () => {
+ const projection = projectSequence(...toArgs(makeWireframeFixture()));
+ const ids = WIREFRAME_NODE_IDS;
+
+ it('emits seven numbered rows in pre-order with unnumbered branch-tail placeholders', () => {
+ const numberedRows = projection.rows.filter((row) => row.stepNumber !== undefined);
+ expect(numberedRows.map((r) => r.nodeId)).toEqual([
+ ids.http,
+ ids.javascript,
+ ids.forEach,
+ ids.ifNode,
+ ids.thenJs,
+ ids.elseHttp,
+ ids.sendMessage,
+ ]);
+ expect(numberedRows.map((r) => r.stepNumber)).toEqual([1, 2, 3, 4, 5, 6, 7]);
+ expect(projection.rows.filter((row) => row.lanePlaceholder)).toHaveLength(2);
+ });
+
+ it('assigns depths from container and branch nesting', () => {
+ expect(rowById(projection, ids.http).depth).toBe(0);
+ expect(rowById(projection, ids.forEach).depth).toBe(0);
+ expect(rowById(projection, ids.ifNode).depth).toBe(1);
+ expect(rowById(projection, ids.thenJs).depth).toBe(2);
+ expect(rowById(projection, ids.elseHttp).depth).toBe(2);
+ expect(rowById(projection, ids.sendMessage).depth).toBe(0);
+ });
+
+ it('marks containers and branch owners collapsible, leaves others not', () => {
+ expect(rowById(projection, ids.forEach).collapsible).toBe(true);
+ expect(rowById(projection, ids.ifNode).collapsible).toBe(true);
+ expect(rowById(projection, ids.http).collapsible).toBe(false);
+ expect(rowById(projection, ids.thenJs).collapsible).toBe(false);
+ });
+
+ it('marks terminal non-container rows as leaves', () => {
+ // Branch-lane terminals and the top-level tail are leaves.
+ expect(rowById(projection, ids.thenJs).isLeaf).toBe(true);
+ expect(rowById(projection, ids.elseHttp).isLeaf).toBe(true);
+ expect(rowById(projection, ids.sendMessage).isLeaf).toBe(true);
+ // Mid-chain rows, containers, and branch sources are never leaves.
+ expect(rowById(projection, ids.http).isLeaf).toBeFalsy();
+ expect(rowById(projection, ids.forEach).isLeaf).toBeFalsy();
+ expect(rowById(projection, ids.ifNode).isLeaf).toBeFalsy();
+ });
+
+ it('carries branch metadata on the first row of each lane', () => {
+ expect(rowById(projection, ids.ifNode).branch?.label).toBe('Body');
+ expect(rowById(projection, ids.thenJs).branch?.label).toBe('Then');
+ expect(rowById(projection, ids.elseHttp).branch?.label).toBe('Else');
+ expect(rowById(projection, ids.http).branch).toBeUndefined();
+ });
+
+ it('emits workflow connectors plus one placeholder connector per populated branch tail', () => {
+ const kinds = projection.connectors.map((c) => c.kind).sort();
+ expect(kinds).toEqual([
+ 'branch-entry',
+ 'branch-entry',
+ 'branch-entry',
+ 'merge-back',
+ 'step',
+ 'step',
+ 'step',
+ 'step',
+ ]);
+ expect(connector(projection, ids.forEach, ids.ifNode).label).toBe('Body');
+ expect(connector(projection, ids.ifNode, ids.thenJs).label).toBe('Then');
+ expect(connector(projection, ids.ifNode, ids.elseHttp).label).toBe('Else');
+ expect(projection.connectors.some((c) => c.kind === 'goto')).toBe(false);
+ });
+
+ it('renders the container-continuation out of For Each as a merge-back that still carries an insertable slot', () => {
+ // Per the concept wireframe, the edge leaving a container AFTER its
+ // indented body (here: For Each -> Send Message) visually rejoins the
+ // spine from the body, exactly like a branch lane's merge-back - so it
+ // must be classified 'merge-back', NOT 'step', even
+ // though it is still a genuine single-edge spine continuation and so,
+ // unlike an ordinary branch merge-back, keeps its InsertionSlot.
+ const forEachToSend = connector(projection, ids.forEach, ids.sendMessage);
+ expect(forEachToSend.kind).toBe('merge-back');
+ expect(forEachToSend.slot).toBeDefined();
+ expect(forEachToSend.slot?.graphEdgeId).toBe('e-foreach-send');
+
+ // A plain step between two non-container same-depth rows is untouched:
+ // http -> javascript stays 'step'.
+ expect(connector(projection, ids.http, ids.javascript).kind).toBe('step');
+ });
+
+ it('keeps real connectors insertable and gives branch-tail placeholders their own slots', () => {
+ const placeholderNodeIds = new Set(
+ projection.rows.filter((row) => row.lanePlaceholder).map((row) => row.nodeId)
+ );
+ for (const c of projection.connectors.filter(
+ (connector) => !placeholderNodeIds.has(connector.targetRowId)
+ )) {
+ expect(c.slot, `${c.kind} ${c.sourceRowId}->${c.targetRowId}`).toBeDefined();
+ }
+ expect(projection.slots).toHaveLength(8);
+ expect(projection.slots).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: `slot:leaf:${ids.thenJs}` }),
+ expect.objectContaining({ id: `slot:leaf:${ids.elseHttp}` }),
+ ])
+ );
+ });
+
+ it('resolves branch labels via the resolver option ahead of edge data', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const withResolver = projectSequence(nodes, edges, {
+ resolveBranchLabel: (nodeId, handleId) =>
+ nodeId === ids.ifNode && handleId === 'true' ? 'Yes' : `${handleId}`,
+ });
+ expect(rowById(withResolver, ids.thenJs).branch?.label).toBe('Yes');
+ // Container start handle resolves through the resolver too.
+ expect(rowById(withResolver, ids.ifNode).branch?.label).toBe('start');
+ });
+ });
+
+ describe('collapse', () => {
+ const ids = WIREFRAME_NODE_IDS;
+
+ it('hides descendants of a collapsed container but keeps numbers stable', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const collapsed = projectSequence(nodes, edges, { collapsedStepIds: new Set([ids.forEach]) });
+ expect(rowById(collapsed, ids.forEach).collapsed).toBe(true);
+ expect(rowById(collapsed, ids.forEach).visible).toBe(true);
+ expect(rowById(collapsed, ids.ifNode).visible).toBe(false);
+ expect(rowById(collapsed, ids.thenJs).visible).toBe(false);
+ expect(rowById(collapsed, ids.sendMessage).visible).toBe(true);
+ // Numbering is unchanged from the expanded projection (D7).
+ expect(rowById(collapsed, ids.forEach).stepNumber).toBe(3);
+ expect(rowById(collapsed, ids.ifNode).stepNumber).toBe(4);
+ expect(rowById(collapsed, ids.sendMessage).stepNumber).toBe(7);
+ });
+
+ it('collapsing a branch owner hides its lanes but not the owner', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const collapsed = projectSequence(nodes, edges, { collapsedStepIds: new Set([ids.ifNode]) });
+ expect(rowById(collapsed, ids.ifNode).visible).toBe(true);
+ expect(rowById(collapsed, ids.ifNode).collapsed).toBe(true);
+ expect(rowById(collapsed, ids.thenJs).visible).toBe(false);
+ expect(rowById(collapsed, ids.elseHttp).visible).toBe(false);
+ });
+ });
+
+ describe('diamond merge (structured post-dominator)', () => {
+ const projection = projectSequence(...toArgs(makeDiamondFixture()));
+
+ it('emits the join node once at the branch owner depth', () => {
+ expect(rowById(projection, 'd').depth).toBe(0);
+ expect(projection.rows.filter((r) => r.nodeId === 'd')).toHaveLength(1);
+ expect(rowById(projection, 'b').depth).toBe(1);
+ expect(rowById(projection, 'c').depth).toBe(1);
+ });
+
+ it('draws merge-back connectors from both lane tails into the join, each still insertable', () => {
+ expect(connector(projection, 'b', 'd').kind).toBe('merge-back');
+ expect(connector(projection, 'c', 'd').kind).toBe('merge-back');
+ // A branch that rejoins the flow must still be appendable at its end: the
+ // lane-closing edge carries the slot that splits it, so you can add a step
+ // before the merge (regression for the "cannot add at the end of the true
+ // path" gap).
+ expect(connector(projection, 'b', 'd').slot?.graphEdgeId).toBe('b-d');
+ expect(connector(projection, 'c', 'd').slot?.graphEdgeId).toBe('c-d');
+ });
+ });
+
+ describe('empty branch body', () => {
+ const projection = projectSequence(...toArgs(makeEmptyBranchFixture()));
+
+ it('produces an empty-branch-body insertion slot for the empty lane', () => {
+ // Id is disambiguated with the edge id (F12) so two default-handle
+ // branches from the same owner can never collide on one slot.
+ const emptySlot = projection.slots.find((s) => s.id === 'slot:empty:if:false:if-c');
+ expect(emptySlot).toBeDefined();
+ expect(emptySlot?.source).toEqual({ nodeId: 'if', handleId: 'false' });
+ // Target is built from the edge itself, so it always carries the split
+ // edge's own targetHandle ('input' here) rather than being dropped
+ // whenever the merge happens to be undefined.
+ expect(emptySlot?.target).toEqual({ nodeId: 'c', handleId: 'input' });
+ });
+
+ it('still draws a labeled branch-entry connector to the join for the empty lane', () => {
+ expect(connector(projection, 'if', 'c').kind).toBe('branch-entry');
+ expect(connector(projection, 'if', 'c').label).toBe('Else');
+ });
+
+ it('preserves the split edge target handle in the empty-lane slot', () => {
+ // Regression for F4: the slot must come from the edge, not from
+ // `merge`, so a targetHandle on the branch edge survives.
+ const { nodes, edges } = makeEmptyBranchFixture();
+ const withHandle = edges.map((e) =>
+ e.id === 'if-c' ? { ...e, targetHandle: 'special-in' } : e
+ );
+ const withHandleProjection = projectSequence(nodes, withHandle);
+ const emptySlot = withHandleProjection.slots.find((s) => s.id === 'slot:empty:if:false:if-c');
+ expect(emptySlot?.target).toEqual({ nodeId: 'c', handleId: 'special-in' });
+ });
+ });
+
+ describe('empty manifest container', () => {
+ it('renders an insertable Body lane placeholder when no structural child remains', () => {
+ const container = {
+ id: 'loop',
+ type: 'uipath.control-flow.foreach',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const projection = projectSequence([container], [], {
+ isContainerNode: (node) => node.id === 'loop',
+ });
+ expect(rowById(projection, 'loop').collapsible).toBe(true);
+ // The empty body is a "+ Add step" lane placeholder row entered mid-left.
+ const laneRow = projection.rows.find(
+ (row) => row.lanePlaceholder?.id === 'slot:lane:loop:start'
+ );
+ expect(laneRow?.depth).toBe(1);
+ expect(laneRow?.lanePlaceholder).toEqual({
+ id: 'slot:lane:loop:start',
+ source: { nodeId: 'loop', handleId: 'start' },
+ containerId: 'loop',
+ });
+ expect(projection.slots).toContainEqual(laneRow?.lanePlaceholder);
+ expect(connector(projection, 'loop', laneRow?.nodeId ?? '').kind).toBe('branch-entry');
+ });
+ });
+
+ describe('declared branch lanes (getBranchHandles)', () => {
+ const ifBranches = (node: { id: string }) =>
+ node.id === 'if'
+ ? [
+ { id: 'true', label: 'Then' },
+ { id: 'false', label: 'Else' },
+ ]
+ : [];
+
+ it('renders every declared lane as a placeholder for a childless parent', () => {
+ const ifNode = { id: 'if', type: 'if', position: { x: 0, y: 0 }, data: {} };
+ const projection = projectSequence([ifNode], [], { getBranchHandles: ifBranches });
+ const laneRows = projection.rows.filter((row) => row.lanePlaceholder);
+ expect(laneRows.map((row) => row.lanePlaceholder?.id)).toEqual([
+ 'slot:lane:if:true',
+ 'slot:lane:if:false',
+ ]);
+ expect(laneRows.every((row) => row.depth === 1)).toBe(true);
+ expect(laneRows.map((row) => row.branch?.label)).toEqual(['Then', 'Else']);
+ expect(connector(projection, 'if', laneRows[0]?.nodeId ?? '').kind).toBe('branch-entry');
+ // The If is a branch parent, never a leaf.
+ expect(rowById(projection, 'if').isLeaf).toBeFalsy();
+ expect(rowById(projection, 'if').collapsible).toBe(true);
+ });
+
+ it('makes every nested declared branch owner collapsible and hides only its descendants', () => {
+ const nodes = [
+ { id: 'outer', type: 'if', position: { x: 0, y: 0 }, data: {} },
+ { id: 'middle', type: 'if', position: { x: 0, y: 100 }, data: {} },
+ { id: 'inner', type: 'if', position: { x: 0, y: 200 }, data: {} },
+ ];
+ const edges = [
+ { id: 'outer-middle', source: 'outer', sourceHandle: 'true', target: 'middle' },
+ { id: 'middle-inner', source: 'middle', sourceHandle: 'true', target: 'inner' },
+ ];
+ const getBranchHandles = (node: Node) =>
+ node.type === 'if'
+ ? [
+ { id: 'true', label: 'True' },
+ { id: 'false', label: 'False' },
+ ]
+ : [];
+ const projection = projectSequence(nodes, edges, {
+ getBranchHandles,
+ collapsedStepIds: new Set(['middle']),
+ });
+
+ expect(['outer', 'middle', 'inner'].map((id) => rowById(projection, id).collapsible)).toEqual(
+ [true, true, true]
+ );
+ expect(rowById(projection, 'inner').visible).toBe(false);
+ expect(
+ projection.rows.find((row) => row.nodeId === '__sequential-lane__middle::false')?.visible
+ ).toBe(false);
+ expect(
+ projection.rows.find((row) => row.nodeId === '__sequential-lane__outer::false')?.visible
+ ).toBe(true);
+ });
+
+ it('uses bottom-anchored continuation geometry after a declared body lane', () => {
+ const nodes = [
+ { id: 'while', type: 'while', position: { x: 0, y: 0 }, data: {} },
+ { id: 'next', position: { x: 0, y: 100 }, data: {} },
+ ];
+ const edges = [
+ { id: 'while-next', source: 'while', sourceHandle: 'success', target: 'next' },
+ ];
+ const projection = projectSequence(nodes, edges, {
+ getBranchHandles: (node) => (node.id === 'while' ? [{ id: 'body', label: 'Body' }] : []),
+ });
+
+ expect(projection.rows.map((row) => row.nodeId)).toEqual([
+ 'while',
+ '__sequential-lane__while::body',
+ 'next',
+ ]);
+ expect(connector(projection, 'while', 'next').kind).toBe('merge-back');
+ });
+
+ it('walks a populated lane and adds the same placeholder after it as the empty lane', () => {
+ const nodes = [
+ { id: 'if', type: 'if', position: { x: 0, y: 0 }, data: {} },
+ { id: 'x', position: { x: 0, y: 0 }, data: {} },
+ ];
+ const edges = [{ id: 'e1', source: 'if', target: 'x', sourceHandle: 'true' }];
+ const projection = projectSequence(nodes, edges, { getBranchHandles: ifBranches });
+ expect(projection.rows.some((row) => row.nodeId === 'x')).toBe(true);
+ const laneRows = projection.rows.filter((row) => row.lanePlaceholder);
+ expect(laneRows.map((row) => row.lanePlaceholder?.id)).toEqual([
+ 'slot:leaf:x',
+ 'slot:lane:if:false',
+ ]);
+ });
+
+ it('keeps an empty lane before a populated lane when that is the declared order', () => {
+ const nodes = [
+ { id: 'if', type: 'if', position: { x: 0, y: 0 }, data: {} },
+ { id: 'x', position: { x: 0, y: 0 }, data: {} },
+ ];
+ const edges = [{ id: 'e1', source: 'if', target: 'x', sourceHandle: 'false' }];
+ const projection = projectSequence(nodes, edges, { getBranchHandles: ifBranches });
+
+ expect(projection.rows.map((row) => row.nodeId)).toEqual([
+ 'if',
+ '__sequential-lane__if::true',
+ 'x',
+ '__sequential-lane__x::__tail__',
+ ]);
+ });
+
+ it('keeps an inserted decision downstream edge on the spine while every branch stays empty', () => {
+ const nodes = [
+ { id: 'a', position: { x: 0, y: 0 }, data: {} },
+ { id: 'if', type: 'if', position: { x: 0, y: 100 }, data: {} },
+ { id: 'b', position: { x: 0, y: 200 }, data: {} },
+ ];
+ const edges = [
+ { id: 'a-if', source: 'a', target: 'if' },
+ {
+ id: 'if-b',
+ source: 'if',
+ sourceHandle: 'true',
+ target: 'b',
+ data: { [SEQ_CONTINUATION_EDGE_KEY]: true },
+ },
+ ];
+ const projection = projectSequence(nodes, edges, { getBranchHandles: ifBranches });
+
+ expect(projection.rows.map((row) => row.nodeId)).toEqual([
+ 'a',
+ 'if',
+ '__sequential-lane__if::true',
+ '__sequential-lane__if::false',
+ 'b',
+ ]);
+ expect(rowById(projection, 'b')).toMatchObject({ depth: 0, parentRowId: undefined });
+ expect(connector(projection, 'if', 'b')).toMatchObject({
+ kind: 'merge-back',
+ slot: { continuation: true },
+ });
+ });
+
+ it('keeps downstream nodes at the insertion level when adding a decision inside a branch', () => {
+ const nodes = [
+ { id: 'outer', type: 'if', position: { x: 0, y: 0 }, data: {} },
+ { id: 'inserted', type: 'if', position: { x: 0, y: 100 }, data: {} },
+ { id: 'downstream', position: { x: 0, y: 200 }, data: {} },
+ ];
+ const edges = [
+ { id: 'outer-inserted', source: 'outer', sourceHandle: 'true', target: 'inserted' },
+ {
+ id: 'inserted-downstream',
+ source: 'inserted',
+ sourceHandle: 'true',
+ target: 'downstream',
+ data: { [SEQ_CONTINUATION_EDGE_KEY]: true },
+ },
+ ];
+ const projection = projectSequence(nodes, edges, {
+ getBranchHandles: (node) =>
+ node.type === 'if'
+ ? [
+ { id: 'true', label: 'Then' },
+ { id: 'false', label: 'Else' },
+ ]
+ : [],
+ });
+
+ expect(rowById(projection, 'inserted')).toMatchObject({ depth: 1, parentRowId: 'outer' });
+ expect(rowById(projection, 'downstream')).toMatchObject({
+ depth: 1,
+ parentRowId: 'outer',
+ });
+ expect(projection.rows.filter((row) => row.parentRowId === 'inserted')).toEqual([
+ expect.objectContaining({
+ placeholderKind: 'lane',
+ branch: expect.objectContaining({ handleId: 'true' }),
+ }),
+ expect.objectContaining({
+ placeholderKind: 'lane',
+ branch: expect.objectContaining({ handleId: 'false' }),
+ }),
+ ]);
+ });
+ });
+
+ describe('nested branch merge detection (F1)', () => {
+ const projection = projectSequence(...toArgs(makeNestedBranchFixture()));
+
+ it('resolves the outer merge through the nested branch and emits it once at the owner depth', () => {
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['a', 'if1', 'if2', 'b', 'c', 'm', 'z']);
+ expect(rowById(projection, 'm').depth).toBe(0);
+ expect(rowById(projection, 'm').parentRowId).toBeUndefined();
+ expect(projection.rows.filter((r) => r.nodeId === 'm')).toHaveLength(1);
+ expect(rowById(projection, 'z').depth).toBe(0);
+ });
+
+ it('draws merge-back connectors from both inner lane tails into the shared merge', () => {
+ expect(connector(projection, 'b', 'm').kind).toBe('merge-back');
+ expect(connector(projection, 'c', 'm').kind).toBe('merge-back');
+ });
+
+ it('renders the outer empty else lane as a legitimate branch-entry into the resolved merge', () => {
+ expect(connector(projection, 'if1', 'm').kind).toBe('branch-entry');
+ });
+
+ it('continues the outer spine past the resolved merge instead of hiding it', () => {
+ expect(connector(projection, 'm', 'z').kind).toBe('step');
+ });
+
+ it('keeps collapsing the outer branch owner from hiding the merge and downstream flow', () => {
+ const { nodes, edges } = makeNestedBranchFixture();
+ const collapsed = projectSequence(nodes, edges, { collapsedStepIds: new Set(['if1']) });
+ expect(rowById(collapsed, 'm').visible).toBe(true);
+ expect(rowById(collapsed, 'z').visible).toBe(true);
+ expect(rowById(collapsed, 'if2').visible).toBe(false);
+ });
+ });
+
+ describe('branch edge classification before rendering (F2/F9)', () => {
+ it('renders a cycle closed through a branch out-edge as goto, never an insertable branch-entry', () => {
+ const projection = projectSequence(...toArgs(makeBranchCycleFixture()));
+ expect(connector(projection, 'b', 'a').kind).toBe('goto');
+ expect(connector(projection, 'b', 'a').slot).toBeUndefined();
+ // The sibling branch that doesn't close a cycle still renders normally.
+ expect(connector(projection, 'b', 'd').kind).toBe('branch-entry');
+ });
+
+ it('renders a branch edge into an already-visited node as goto, never an insertable branch-entry', () => {
+ const projection = projectSequence(...toArgs(makeBranchVisitedTargetFixture()));
+ expect(connector(projection, 'y', 'm').kind).toBe('goto');
+ expect(connector(projection, 'y', 'm').slot).toBeUndefined();
+ // The lane that legitimately reaches M first still renders normally.
+ expect(connector(projection, 'if', 'm').kind).toBe('branch-entry');
+ });
+ });
+
+ describe('multi-root', () => {
+ const projection = projectSequence(...toArgs(makeMultiRootFixture()));
+
+ it('stacks disconnected components ordered by flow y', () => {
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['a', 'b', 'c', 'd']);
+ });
+ });
+
+ describe('unstructured merge', () => {
+ const projection = projectSequence(...toArgs(makeUnstructuredMergeFixture()));
+
+ it('places the merge under the first incomer and gotos the rest', () => {
+ expect(projection.rows.filter((r) => r.nodeId === 'm')).toHaveLength(1);
+ expect(connector(projection, 'x', 'm').kind).toBe('step');
+ expect(connector(projection, 'y', 'm').kind).toBe('goto');
+ expect(connector(projection, 'y', 'm').slot).toBeUndefined();
+ });
+ });
+
+ describe('cycles', () => {
+ it('breaks a non-loopBack cycle with a goto', () => {
+ const projection = projectSequence(...toArgs(makeCycleFixture()));
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['a', 'b', 'c']);
+ expect(connector(projection, 'c', 'a').kind).toBe('goto');
+ });
+
+ it('consumes a loopBack edge as loop closure, not a cycle', () => {
+ const projection = projectSequence(...toArgs(makeLoopBackFixture()));
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['a', 'b']);
+ expect(projection.connectors.some((c) => c.kind === 'goto')).toBe(false);
+ });
+ });
+
+ describe('orphans', () => {
+ const projection = projectSequence(...toArgs(makeOrphanFixture()));
+
+ it('appends disconnected nodes as de-emphasized trailing rows', () => {
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['a', 'b', 'z']);
+ expect(rowById(projection, 'z').depth).toBe(0);
+ expect(rowById(projection, 'z').orphan).toBe(true);
+ });
+ });
+
+ describe('deep nesting', () => {
+ const projection = projectSequence(...toArgs(makeDeepNestingFixture()));
+
+ it('accumulates depth across nested containers', () => {
+ expect(rowById(projection, 'root').depth).toBe(0);
+ expect(rowById(projection, 'c1').depth).toBe(0);
+ expect(rowById(projection, 'c2').depth).toBe(1);
+ expect(rowById(projection, 'leaf').depth).toBe(2);
+ expect(rowById(projection, 'c1').collapsible).toBe(true);
+ expect(rowById(projection, 'c2').collapsible).toBe(true);
+ expect(rowById(projection, 'leaf').collapsible).toBe(false);
+ });
+ });
+
+ describe('single-node graph is a trivial entry, not an orphan (F8)', () => {
+ it('reports a single edge-less node as one numbered, non-orphan row', () => {
+ const projection = projectSequence(...toArgs(makeSingleNodeFixture()));
+ expect(projection.rows).toHaveLength(1);
+ expect(projection.rows[0]?.nodeId).toBe('only');
+ expect(projection.rows[0]?.stepNumber).toBe(1);
+ });
+ });
+
+ describe('synthetic start ownership', () => {
+ const trigger = {
+ id: 'trigger',
+ type: 'uipath.first-run',
+ position: { x: 0, y: 0 },
+ data: {},
+ };
+ const step = {
+ id: 'step',
+ type: 'uipath.script',
+ position: { x: 0, y: 100 },
+ data: {},
+ };
+ const edge = { id: 'trigger-step', source: 'trigger', target: 'step' };
+
+ it('omits the canonical first-run node and its incident edge', () => {
+ const projection = projectSequence([trigger, step], [edge]);
+ expect(projection.rows.map((row) => row.nodeId)).toEqual(['step']);
+ expect(projection.rows[0]?.stepNumber).toBe(1);
+ expect(projection.connectors).toEqual([]);
+ });
+
+ it('supports host-defined trigger manifests without schema coupling', () => {
+ const customTrigger = { ...trigger, id: 'custom', type: 'acme.trigger' };
+ const projection = projectSequence([customTrigger, step], [{ ...edge, source: 'custom' }], {
+ isStartNode: (node) => node.type === 'acme.trigger',
+ });
+ expect(projection.rows.map((row) => row.nodeId)).toEqual(['step']);
+ });
+ });
+
+ describe('orphans form a trailing section (F11)', () => {
+ it('never interleaves an orphan between two components ordered by flow y', () => {
+ const projection = projectSequence(...toArgs(makeInterleavedOrphanFixture()));
+ expect(projection.rows.map((r) => r.nodeId)).toEqual(['p1', 'p2', 'q1', 'q2', 'orphan']);
+ });
+ });
+
+ describe('robustness', () => {
+ it('returns an empty projection for an empty graph', () => {
+ const projection = projectSequence([], []);
+ expect(projection).toEqual({ rows: [], connectors: [], slots: [] });
+ });
+
+ it('never drops a node even when unreachable', () => {
+ const { nodes, edges } = makeWireframeFixture();
+ const projection = projectSequence(nodes, edges);
+ const canonicalRowIds = new Set(
+ projection.rows.filter((row) => !row.lanePlaceholder).map((row) => row.nodeId)
+ );
+ expect(canonicalRowIds).toEqual(
+ new Set(nodes.filter((node) => node.type !== 'uipath.first-run').map((node) => node.id))
+ );
+ });
+ });
+});
+
+/** Spreads a fixture into positional projectSequence args. */
+function toArgs(
+ fixture: ReturnType
+): [typeof fixture.nodes, typeof fixture.edges] {
+ return [fixture.nodes, fixture.edges];
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/projectSequence.ts b/packages/apollo-react/src/canvas/utils/sequential/projectSequence.ts
new file mode 100644
index 000000000..049b891ce
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/projectSequence.ts
@@ -0,0 +1,558 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { DEFAULT_TRIGGER_NODE_TYPE } from '../../constants';
+import {
+ buildGraphIndex,
+ defaultIsSequenceEdge,
+ edgeDataLabel,
+ entriesForScope,
+ forwardOut,
+ isBranchSource,
+ isLoopBackEdge,
+ isSequentialContinuationEdge,
+ isContainerNode as isStructuralContainerNode,
+ lanePlaceholderId,
+ leafPlaceholderId,
+ type Scope,
+ sortByFlow,
+ sourceHandleOf,
+} from './graph-helpers';
+import { findMerge } from './mergeAnalysis';
+import type {
+ InsertionSlot,
+ ProjectSequenceOptions,
+ SequenceConnector,
+ SequenceConnectorKind,
+ SequenceProjection,
+ SequenceRow,
+} from './sequential.types';
+
+const CONTAINER_START_LABEL = 'Body';
+const CONTAINER_START_HANDLE = 'start';
+const EMPTY_SCOPE: ReadonlySet = new Set();
+
+/**
+ * Pure, structure-only projection of the canonical graph into sequential rows,
+ * connectors, and insertion slots.
+ *
+ * Algorithm (see the module helpers for the primitives):
+ * - Sequence edges = non-preview edges passing `isSequenceEdge`; `loopBack`
+ * edges are consumed as loop closure, never traversed as forward steps.
+ * - Walk begins at the entry set (nodes with no in-scope forward incomer),
+ * ordered by flow-view y. A DFS pre-order assigns the flat `stepNumber`
+ * counter BEFORE any collapse filtering (D7); synthetic rows do not exist at
+ * this layer (start bar + placeholder are injected view-only by the assembly).
+ * - Containers (a node with children) open an indented "Body" lane; branch
+ * nodes (>1 forward edge) open one labeled lane per branch. Both close at a
+ * shared merge (immediate post-dominator, see mergeAnalysis); lane tails draw
+ * `merge-back` connectors into it.
+ * - Malformed shapes never hard-fail: multi-root => stacked lanes; unstructured
+ * merge => first-incomer-wins + `goto`; non-loopBack cycle => dashed upward
+ * `goto` + visited-set guard (prevents infinite recursion); orphans =>
+ * appended trailing rows so a node is never silently dropped.
+ * - `slots` include the empty-branch-body case so an empty lane still shows an
+ * insert point.
+ */
+export function projectSequence(
+ nodes: Node[],
+ edges: Edge[],
+ options?: ProjectSequenceOptions
+): SequenceProjection {
+ const isSequenceEdge = options?.isSequenceEdge ?? defaultIsSequenceEdge;
+ const isStartNode =
+ options?.isStartNode ?? ((node: Node) => node.type === DEFAULT_TRIGGER_NODE_TYPE);
+ const collapsedSet = options?.collapsedStepIds ?? EMPTY_SCOPE;
+ const sequenceNodes = nodes.filter((node) => !isStartNode(node));
+ const sequenceNodeIds = new Set(sequenceNodes.map((node) => node.id));
+ const sequenceEdges = edges.filter(
+ (edge) => sequenceNodeIds.has(edge.source) && sequenceNodeIds.has(edge.target)
+ );
+ const index = buildGraphIndex(sequenceNodes, sequenceEdges, isSequenceEdge);
+
+ const rows: SequenceRow[] = [];
+ const rowByNodeId = new Map();
+ const connectors: SequenceConnector[] = [];
+ const slotsById = new Map();
+ const isContainer = (nodeId: string): boolean => {
+ const node = index.nodesById.get(nodeId);
+ return (
+ isStructuralContainerNode(index, nodeId) ||
+ (!!node && options?.isContainerNode?.(node) === true)
+ );
+ };
+ const declaredBranchHandlesByNodeId = new Map();
+ const declaredBranchHandles = (nodeId: string): { id: string; label: string }[] => {
+ if (isContainer(nodeId)) return [];
+ const cached = declaredBranchHandlesByNodeId.get(nodeId);
+ if (cached) return cached;
+ const node = index.nodesById.get(nodeId);
+ const handles = node ? (options?.getBranchHandles?.(node) ?? []) : [];
+ declaredBranchHandlesByNodeId.set(nodeId, handles);
+ return handles;
+ };
+
+ const visited = new Set();
+ const onStack = new Set();
+ let counter = 0;
+
+ const addSlot = (slot: InsertionSlot): InsertionSlot => {
+ const existing = slotsById.get(slot.id);
+ if (existing) return existing;
+ slotsById.set(slot.id, slot);
+ return slot;
+ };
+
+ const slotFromEdge = (edge: Edge, containerId: Scope): InsertionSlot =>
+ addSlot({
+ id: `slot:edge:${edge.id}`,
+ source: { nodeId: edge.source, handleId: edge.sourceHandle ?? undefined },
+ target: { nodeId: edge.target, handleId: edge.targetHandle ?? undefined },
+ graphEdgeId: edge.id,
+ containerId: containerId ?? undefined,
+ continuation: isSequentialContinuationEdge(edge) || undefined,
+ });
+
+ const pushConnector = (
+ kind: SequenceConnectorKind,
+ id: string,
+ source: string,
+ target: string,
+ label?: string,
+ slot?: InsertionSlot
+ ): void => {
+ connectors.push({ id, kind, sourceRowId: source, targetRowId: target, label, slot });
+ };
+
+ const emitRow = (
+ nodeId: string,
+ depth: number,
+ parentRowId: string | undefined,
+ branch: SequenceRow['branch'],
+ orphan = false
+ ): void => {
+ counter += 1;
+ const scope = index.nodesById.get(nodeId)?.parentId;
+ const row: SequenceRow = {
+ nodeId,
+ stepNumber: counter,
+ depth,
+ parentRowId,
+ branch,
+ collapsible:
+ isContainer(nodeId) ||
+ isBranchSource(index, nodeId, scope) ||
+ declaredBranchHandles(nodeId).length > 0,
+ collapsed: collapsedSet.has(nodeId),
+ visible: true, // resolved in a post-pass once every row exists
+ orphan: orphan || undefined,
+ };
+ rows.push(row);
+ rowByNodeId.set(nodeId, row);
+ visited.add(nodeId);
+ };
+
+ const branchLabel = (ownerId: string, edge: Edge): string =>
+ options?.resolveBranchLabel?.(ownerId, sourceHandleOf(edge)) ??
+ edgeDataLabel(edge) ??
+ edge.sourceHandle ??
+ 'Branch';
+
+ // Synthetic empty-lane placeholder: a parent's declared branch handle with no
+ // child yet. Renders as an indented dashed "+ Add step" bar entered mid-left,
+ // carrying the slot that appends the first node into that lane. Not added to
+ // `visited`/`rowByNodeId` — it has no canonical node and is never numbered.
+ const emitLanePlaceholder = (
+ ownerId: string,
+ depth: number,
+ parentRowId: string | undefined,
+ handle: { id: string; label: string }
+ ): void => {
+ const placeholderNodeId = lanePlaceholderId(ownerId, handle.id);
+ const slot: InsertionSlot = {
+ id: `slot:lane:${ownerId}:${handle.id}`,
+ source: { nodeId: ownerId, handleId: handle.id },
+ containerId: isContainer(ownerId)
+ ? ownerId
+ : (index.nodesById.get(ownerId)?.parentId ?? undefined),
+ };
+ addSlot(slot);
+ rows.push({
+ nodeId: placeholderNodeId,
+ depth,
+ parentRowId,
+ branch: { sourceNodeId: ownerId, handleId: handle.id, label: handle.label },
+ collapsible: false,
+ collapsed: false,
+ visible: true,
+ lanePlaceholder: slot,
+ placeholderKind: 'lane',
+ });
+ // No ⊕ slot on the connector — the placeholder bar itself is the affordance.
+ pushConnector(
+ 'branch-entry',
+ `conn:lane:${ownerId}:${handle.id}`,
+ ownerId,
+ placeholderNodeId,
+ handle.label
+ );
+ };
+
+ function walkContainerBody(containerNode: Node, depth: number): void {
+ const scope = containerNode.id;
+ const entries = entriesForScope(index, scope);
+
+ if (entries.length === 0) {
+ // Empty container body: render a "Body" lane placeholder ("+ Add step")
+ // so the empty body is visible and insertable, entered mid-left like any
+ // branch lane. The body handle/label come from the manifest when known.
+ const bodyHandle = options?.getBranchHandles?.(containerNode)?.[0] ?? {
+ id: CONTAINER_START_HANDLE,
+ label: CONTAINER_START_LABEL,
+ };
+ emitLanePlaceholder(containerNode.id, depth + 1, containerNode.id, bodyHandle);
+ return;
+ }
+
+ for (const entry of entries) {
+ const startEdge = index.seqEdges.find(
+ (edge) => edge.source === scope && edge.target === entry.id
+ );
+ const label =
+ options?.resolveBranchLabel?.(scope, startEdge?.sourceHandle ?? CONTAINER_START_HANDLE) ??
+ (startEdge ? edgeDataLabel(startEdge) : undefined) ??
+ CONTAINER_START_LABEL;
+ const slot = startEdge
+ ? slotFromEdge(startEdge, scope)
+ : addSlot({
+ id: `slot:start:${scope}:${entry.id}`,
+ source: { nodeId: scope, handleId: CONTAINER_START_HANDLE },
+ target: { nodeId: entry.id },
+ containerId: scope,
+ });
+ pushConnector(
+ 'branch-entry',
+ startEdge ? `conn:${startEdge.id}` : `conn:start:${scope}:${entry.id}`,
+ containerNode.id,
+ entry.id,
+ label,
+ slot
+ );
+ walkSpine(
+ entry.id,
+ scope,
+ depth + 1,
+ containerNode.id,
+ { sourceNodeId: scope, handleId: startEdge?.sourceHandle ?? CONTAINER_START_HANDLE, label },
+ EMPTY_SCOPE
+ );
+ }
+ }
+
+ function walkBranch(
+ ownerId: string,
+ branchEdges: Edge[],
+ scope: Scope,
+ depth: number,
+ outerStop: ReadonlySet,
+ resolvedMerge?: string
+ ): string | undefined {
+ const merge = resolvedMerge ?? findMerge(index, branchEdges, scope);
+ const laneStop: ReadonlySet = merge ? new Set([...outerStop, merge]) : outerStop;
+
+ for (const edge of branchEdges) {
+ const target = edge.target;
+
+ // Classify the target BEFORE emitting anything: a branch edge that closes
+ // a cycle or jumps into a node already placed by another lane is a
+ // cycle/unstructured-merge (D9), never an insertable branch-entry lane.
+ // This mirrors walkSpine's single-successor handling below.
+ if (onStack.has(target)) {
+ pushConnector('goto', `conn:goto:${edge.id}`, ownerId, target);
+ continue;
+ }
+ if (visited.has(target) && target !== merge) {
+ pushConnector('goto', `conn:goto:${edge.id}`, ownerId, target);
+ continue;
+ }
+
+ const label = branchLabel(ownerId, edge);
+ const targetNode = index.nodesById.get(target);
+ const slot = slotFromEdge(edge, scope);
+ const isEmptyLane =
+ target === merge || outerStop.has(target) || !targetNode || targetNode.parentId !== scope;
+ if (isEmptyLane) {
+ pushConnector('branch-entry', `conn:${edge.id}`, ownerId, target, label, slot);
+ addSlot({
+ // Disambiguated with the edge id: two branch edges sharing the
+ // default source handle (e.g. imported/degraded graphs) must not
+ // collide on the same empty-lane slot.
+ id: `slot:empty:${ownerId}:${sourceHandleOf(edge)}:${edge.id}`,
+ source: { nodeId: ownerId, handleId: edge.sourceHandle ?? undefined },
+ // Built from the edge itself, not from `merge`: this is the same
+ // endpoint the connector splits regardless of whether findMerge
+ // resolved a shared join, so the handle is never lost.
+ target: { nodeId: edge.target, handleId: edge.targetHandle ?? undefined },
+ graphEdgeId: edge.id,
+ containerId: scope ?? undefined,
+ });
+ continue;
+ }
+
+ pushConnector('branch-entry', `conn:${edge.id}`, ownerId, target, label, slot);
+ walkSpine(
+ target,
+ scope,
+ depth + 1,
+ ownerId,
+ { sourceNodeId: ownerId, handleId: sourceHandleOf(edge), label },
+ laneStop
+ );
+ }
+ return merge;
+ }
+
+ function walkSpine(
+ startId: string,
+ scope: Scope,
+ depth: number,
+ parentRowIdArg: string | undefined,
+ branchMetaArg: SequenceRow['branch'],
+ stopBefore: ReadonlySet
+ ): void {
+ let currentId: string | undefined = startId;
+ const parentRowId = parentRowIdArg;
+ let branchMeta = branchMetaArg;
+ let firstRow = true;
+ const localStack: string[] = [];
+
+ // Advance the spine to a single forward successor, pushing the connecting
+ // edge. Returns the next node id, or undefined when the walk must stop
+ // (merge-back / goto / cycle). Shared by the plain single-successor case and
+ // a declared branch node's continuation output.
+ const stepToSuccessor = (
+ fromId: string,
+ edge: Edge,
+ followsIndentedContent = false
+ ): string | undefined => {
+ const next = edge.target;
+ if (stopBefore.has(next)) {
+ // A branch/loop lane closing into the shared merge. The closing edge is a
+ // real graph edge, so it stays insertable: splitting it appends a step to
+ // the END of this lane (before the merge), the same as any other gap. The
+ // merge-back geometry is kept; only the missing slot is restored.
+ pushConnector(
+ 'merge-back',
+ `conn:merge:${edge.id}`,
+ fromId,
+ next,
+ undefined,
+ slotFromEdge(edge, scope)
+ );
+ return undefined;
+ }
+ if (onStack.has(next) || visited.has(next)) {
+ pushConnector('goto', `conn:goto:${edge.id}`, fromId, next);
+ return undefined;
+ }
+ pushConnector(
+ isContainer(fromId) || followsIndentedContent ? 'merge-back' : 'step',
+ `conn:${edge.id}`,
+ fromId,
+ next,
+ undefined,
+ slotFromEdge(edge, scope)
+ );
+ return next;
+ };
+
+ while (currentId !== undefined) {
+ if (currentId === scope || stopBefore.has(currentId)) break;
+ const node = index.nodesById.get(currentId);
+ if (!node || node.parentId !== scope || visited.has(currentId)) break;
+
+ emitRow(currentId, depth, parentRowId, firstRow ? branchMeta : undefined);
+ onStack.add(currentId);
+ localStack.push(currentId);
+ firstRow = false;
+
+ if (isContainer(currentId)) walkContainerBody(node, depth);
+
+ const out = forwardOut(index, currentId, scope);
+ // Manifest-declared branch lanes (If → then/else, while → body, try/catch)
+ // for a non-container parent. When present, the node's forward flow is
+ // routed through its DECLARED handles rather than the edge-count heuristic,
+ // so every lane renders — populated ones walk, empty ones get a "+ Add
+ // step" placeholder — regardless of how many branches are wired yet.
+ const branchHandles = declaredBranchHandles(currentId);
+ const isDeclaredBranch: boolean = branchHandles.length > 0;
+
+ if (out.length === 0 && !isDeclaredBranch) {
+ // Terminal nested rows get the same full-width dashed Add step row as
+ // an empty branch. This keeps populated and empty branch insertion UX
+ // identical; the top-level tail remains served by the canvas's single
+ // terminal placeholder.
+ const leafRow = rowByNodeId.get(currentId);
+ if (leafRow && !isContainer(currentId)) {
+ leafRow.isLeaf = true;
+ if (depth > 0) {
+ const slot: InsertionSlot = {
+ id: `slot:leaf:${currentId}`,
+ source: { nodeId: currentId },
+ containerId: scope,
+ };
+ addSlot(slot);
+ const placeholderNodeId = leafPlaceholderId(currentId);
+ rows.push({
+ nodeId: placeholderNodeId,
+ depth,
+ parentRowId: leafRow.parentRowId,
+ collapsible: false,
+ collapsed: false,
+ visible: leafRow.visible,
+ lanePlaceholder: slot,
+ placeholderKind: 'append',
+ });
+ pushConnector('step', `conn:leaf:${currentId}`, currentId, placeholderNodeId);
+ }
+ }
+ break;
+ }
+
+ if (out.length > 1 || isDeclaredBranch) {
+ // Split a declared branch node's outputs into lane edges (declared
+ // handles) vs a single continuation output (e.g. a loop's Completed).
+ const branchHandleIds = new Set(branchHandles.map((h) => h.id));
+ const laneEdges: Edge[] = isDeclaredBranch
+ ? out.filter(
+ (edge) =>
+ !isSequentialContinuationEdge(edge) && branchHandleIds.has(sourceHandleOf(edge))
+ )
+ : out;
+ const continuationEdges: Edge[] = isDeclaredBranch
+ ? out.filter(
+ (edge) =>
+ isSequentialContinuationEdge(edge) || !branchHandleIds.has(sourceHandleOf(edge))
+ )
+ : [];
+
+ let merge: string | undefined;
+ if (isDeclaredBranch) {
+ // Compute the merge from every populated lane, then emit each lane in
+ // manifest order. This keeps an empty Then lane before a populated
+ // Else lane instead of grouping all populated lanes first.
+ merge = laneEdges.length > 0 ? findMerge(index, laneEdges, scope) : undefined;
+ for (const handle of branchHandles) {
+ const handleEdges = laneEdges.filter((edge) => sourceHandleOf(edge) === handle.id);
+ if (handleEdges.length === 0) {
+ emitLanePlaceholder(currentId, depth + 1, currentId, handle);
+ } else {
+ walkBranch(currentId, handleEdges, scope, depth, stopBefore, merge);
+ }
+ }
+ } else {
+ merge =
+ laneEdges.length > 0
+ ? walkBranch(currentId, laneEdges, scope, depth, stopBefore)
+ : undefined;
+ }
+
+ // Continue below the branches: prefer the branch merge; else follow a
+ // single continuation output as a plain spine step.
+ if (
+ merge !== undefined &&
+ !stopBefore.has(merge) &&
+ !visited.has(merge) &&
+ !onStack.has(merge)
+ ) {
+ currentId = merge;
+ branchMeta = undefined;
+ continue;
+ }
+ if (continuationEdges.length === 1) {
+ const nextId = stepToSuccessor(currentId, continuationEdges[0]!, true);
+ if (nextId !== undefined) {
+ currentId = nextId;
+ branchMeta = undefined;
+ continue;
+ }
+ }
+ break;
+ }
+
+ // Plain single successor (not a declared branch). A container's own
+ // forward continuation rejoins the spine after its indented body, so it
+ // uses merge-back geometry while keeping its slot. Straight container
+ // continuations render solid in SequentialConnectorEdge.
+ const nextId = stepToSuccessor(currentId, out[0]!);
+ if (nextId === undefined) break;
+ currentId = nextId;
+ }
+
+ for (const id of localStack) onStack.delete(id);
+ }
+
+ // Nodes touched by at least one real (non-loopBack) sequence edge. A node with
+ // none is an orphan even if its in-degree is zero, so it must not seed a lane.
+ const incidentNodeIds = new Set();
+ for (const edge of index.seqEdges) {
+ if (isLoopBackEdge(edge)) continue;
+ incidentNodeIds.add(edge.source);
+ incidentNodeIds.add(edge.target);
+ }
+ // A graph with no sequence edges anywhere has no "real" content for a node to
+ // be orphaned relative to: every top-level node (most commonly, the single
+ // node on an otherwise-empty canvas) is trivially its own one-row sequence
+ // rather than a de-emphasized orphan (D9).
+ const hasSequenceContent = incidentNodeIds.size > 0;
+
+ // Top-level roots: entries with no forward incomer, stacked by flow-view y.
+ const topEntries = entriesForScope(index, undefined).filter((node) =>
+ incidentNodeIds.has(node.id)
+ );
+ for (const entry of topEntries) {
+ walkSpine(entry.id, undefined, 0, undefined, undefined, EMPTY_SCOPE);
+ }
+
+ // Disconnected / cyclic top-level components with no zero-in-degree entry,
+ // and genuinely edge-less nodes. Orphans are collected here but their rows
+ // are emitted only after every lane has been walked, so an orphan positioned
+ // between two components (in flow-y order) always ends up in the trailing
+ // section (D9) instead of splitting the two lanes' rows apart.
+ const topLevel = index.childrenByParent.get(undefined) ?? [];
+ const orphanNodes: Node[] = [];
+ for (const node of sortByFlow(topLevel)) {
+ if (visited.has(node.id)) continue;
+ if (incidentNodeIds.has(node.id)) {
+ walkSpine(node.id, undefined, 0, undefined, undefined, EMPTY_SCOPE);
+ } else if (hasSequenceContent) {
+ orphanNodes.push(node);
+ } else {
+ // No sequence content anywhere in the graph: this edge-less node is a
+ // trivial entry (e.g. the first node on an empty canvas), not an orphan.
+ // Use the normal spine walk so a manifest-known empty container still
+ // emits its Body insertion slot and structural children, if any.
+ walkSpine(node.id, undefined, 0, undefined, undefined, EMPTY_SCOPE);
+ }
+ }
+
+ for (const node of orphanNodes) {
+ emitRow(node.id, 0, undefined, undefined, true);
+ }
+
+ // Any node never reached (e.g. a child of an unreachable container) is
+ // appended as an orphan so the projection never silently drops a node.
+ for (const node of sequenceNodes) {
+ if (!visited.has(node.id)) {
+ emitRow(node.id, 0, undefined, undefined, true);
+ }
+ }
+
+ // Visibility: a row is hidden when any ancestor row is collapsed. Rows are in
+ // pre-order so a parent's resolved visibility is always available first (D7).
+ const rowsById = new Map(rows.map((row) => [row.nodeId, row]));
+ for (const row of rows) {
+ const parent = row.parentRowId ? rowsById.get(row.parentRowId) : undefined;
+ row.visible = parent ? parent.visible && !parent.collapsed : true;
+ }
+
+ return { rows, connectors, slots: [...slotsById.values()] };
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/sequential.types.ts b/packages/apollo-react/src/canvas/utils/sequential/sequential.types.ts
new file mode 100644
index 000000000..38fe9dfdf
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/sequential.types.ts
@@ -0,0 +1,193 @@
+/**
+ * Sequential Canvas View — frozen contracts.
+ *
+ * These are the data shapes shared by the projection/layout engine, the
+ * bar visuals, the connector/insert pipeline, and the canvas
+ * assembly. They are intentionally renderer-agnostic: the projection is
+ * a pure derivation of the canonical `nodes`/`edges` graph, and the flow-view
+ * positions remain the persisted truth.
+ *
+ * Binding design decisions:
+ * D1 New `view` axis (CanvasView = 'flow' | 'sequential'); never touches
+ * BaseCanvasProps.mode, CanvasMode, or AgentFlowProps.mode.
+ * D2 Card and bar are sibling node renderers selected by each canvas's
+ * NodeTypes registry, not by wire schema or node data.
+ * D3 No trailing slot added to BaseNodeOverrideConfig; the bar's right-aligned
+ * group consumes the same shared presentation model as BaseNode.
+ * D4 Positions are never written back; sequential-only inserts are stamped
+ * data.seqInserted + crypto.randomUUID() and placed by
+ * synthesizePositionsForFlow() on toggle-to-flow.
+ * D5 Insert rides the existing AddNodeManager pipeline (never forks it);
+ * free-form position stripped; sequential node types go in ignoredNodeTypes.
+ * D6 Collapse is view-local via controlled collapsedStepIds; never node.hidden.
+ * D7 Step numbers are assigned before collapse filtering, as one flat
+ * pre-order counter; synthetic rows (start bar, placeholder) are unnumbered.
+ * D8 Accessibility is a v1 feature; SequentialCanvas sets
+ * onlyRenderVisibleElements={false} so DOM order equals reading order.
+ * D9 Degrade, never hard-fail; every non-representable shape (multi-root,
+ * unstructured merge, cycle, orphan) still renders something rather than
+ * crashing or dropping a node.
+ * D10 Pure mutation ops (insertAtSlot / removeStep / moveStep -> GraphChangeSet)
+ * are the internal semantic core; the public API stays onNodesChange /
+ * onEdgesChange.
+ * D11 Toggle is an in-place array swap on the same mounted BaseCanvas (no key
+ * remount) with fitView({ duration: 300 }) and per-view viewport save/restore.
+ * D12 projectSequence memoizes on a structural fingerprint (sequenceFingerprint);
+ * data-only changes reuse the cached projection and positions.
+ * D13 SequentialCanvas stays out of components/index.ts until GA; types export
+ * first, component last.
+ * D14 Repo standards: Tailwind literals via apollo-wind only, lingui
+ * sequential-canvas.* ids, no em dash in story copy, colocated vitest tests.
+ */
+
+import type { Edge, Node, Rect, XYPosition } from '@uipath/apollo-react/canvas/xyflow/react';
+import type { Waypoint } from '../../components/Edges/shared/types';
+
+/** View axis, orthogonal to every existing `mode` (D1). */
+export type CanvasView = 'flow' | 'sequential';
+
+/** A single row of the sequential projection, in pre-order. */
+export interface SequenceRow {
+ nodeId: string;
+ /**
+ * Flat pre-order number, assigned BEFORE collapse filtering (D7). Absent on
+ * synthetic rows (start bar, placeholder).
+ */
+ stepNumber?: number;
+ /** Indent level; layout uses `x = depth * SEQ_INDENT_PX`. */
+ depth: number;
+ /** Owning container or branch row, if any. */
+ parentRowId?: string;
+ /** Branch metadata for rows that open a labeled lane ("Then"/"Else"/"Body"). */
+ branch?: { sourceNodeId: string; handleId: string; label: string };
+ /** True for containers and branch owners. */
+ collapsible: boolean;
+ collapsed: boolean;
+ /** False when hidden by a collapsed ancestor. */
+ visible: boolean;
+ /** True for a disconnected node rendered in the trailing orphan section. */
+ orphan?: boolean;
+ /**
+ * Terminal non-container row with no forward sequence continuation. Nested
+ * leaves are followed by a full-width lane placeholder; the top-level tail is
+ * served by the canvas's terminal placeholder.
+ */
+ isLeaf?: boolean;
+ /**
+ * Set on a synthetic placeholder row (a parent's empty declared branch handle
+ * or empty container body, or the trailing add point after a populated lane's
+ * last step). The carried slot appends a node into that scope with the correct
+ * source handle + containment. Such rows have no canonical node and are never
+ * numbered, forwarded, or keyboard-focused. See {@link placeholderKind} for how
+ * the two shapes render.
+ */
+ lanePlaceholder?: InsertionSlot;
+ /**
+ * Which add-step affordance a {@link lanePlaceholder} row renders as:
+ * - 'lane' : a genuinely EMPTY branch lane or container body. Renders as the
+ * dashed "Add step" row, because there is no connector to host a plus.
+ * - 'append' : the trailing add point AFTER the last step of a populated lane
+ * or body. Renders as the same quiet plus used between steps, so
+ * adding a step looks identical wherever a line already exists.
+ */
+ placeholderKind?: 'lane' | 'append';
+}
+
+/** An insertion point between two endpoints (graft: hybrid). */
+export interface InsertionSlot {
+ id: string;
+ /** Endpoints the new node splices between. */
+ source?: { nodeId: string; handleId?: string };
+ target?: { nodeId: string; handleId?: string };
+ /** Edge being split, if any. */
+ graphEdgeId?: string;
+ /** parentId for the inserted node. */
+ containerId?: string;
+ /** The split edge is an explicit spine continuation, not branch/body content. */
+ continuation?: boolean;
+}
+
+export type SequenceConnectorKind = 'step' | 'branch-entry' | 'merge-back' | 'goto';
+
+export interface SequenceConnector {
+ id: string;
+ kind: SequenceConnectorKind;
+ sourceRowId: string;
+ targetRowId: string;
+ /** Resolved branch label. */
+ label?: string;
+ /** Present iff insertable; `goto` connectors carry none. */
+ slot?: InsertionSlot;
+}
+
+/** The complete, stateless derivation of a graph into sequential form. */
+export interface SequenceProjection {
+ /** Rows in pre-order. */
+ rows: SequenceRow[];
+ connectors: SequenceConnector[];
+ /** Includes empty-branch-body slots. */
+ slots: InsertionSlot[];
+}
+
+/**
+ * Options for {@link projectSequence}. Named form of the inline option shape.
+ */
+export interface ProjectSequenceOptions {
+ /**
+ * Defaults to `defaultIsSequenceEdge` (graph-helpers.ts), which counts every
+ * non-preview edge as a sequence edge: this pure layer has no manifest
+ * registry access, so it cannot resolve `handleType === 'artifact'` the way
+ * `utils/container.ts:766`'s `isSequenceEdge` does. Callers with registry
+ * access (e.g. the agent-flow consumer) must pass a registry-aware predicate
+ * to exclude artifact/resource handles; otherwise those edges are traversed
+ * as forward steps.
+ */
+ isSequenceEdge?: (edge: Edge) => boolean;
+ /** Start/trigger nodes are represented by the synthetic Workflow start row. */
+ isStartNode?: (node: Node) => boolean;
+ /** Manifest-aware container predicate, needed to preserve empty container bodies. */
+ isContainerNode?: (node: Node) => boolean;
+ resolveBranchLabel?: (nodeId: string, handleId: string) => string;
+ /**
+ * Manifest-driven branch lanes for a parent node (If → then/else, while →
+ * body, try/catch → try/catch/finally). Returns the node's declared branch
+ * source handles (NOT the continuation/default source), so the projection can
+ * render every lane — including empty ones as "+ Add step" placeholders —
+ * before any child edge exists. Returns [] for non-parent nodes. Registry-less
+ * callers omit it and get the legacy edge-count branch discovery only.
+ */
+ getBranchHandles?: (node: Node) => { id: string; label: string }[];
+ collapsedStepIds?: ReadonlySet;
+}
+
+/**
+ * Options for {@link layoutSequence}. Named form of the inline option shape.
+ */
+export interface LayoutSequenceOptions {
+ barWidth?: number;
+ barHeight?: number;
+ rowGap?: number;
+ indent?: number;
+}
+
+/**
+ * Geometry produced by {@link layoutSequence}. Named return shape so the gutter
+ * and assembly can reference it.
+ */
+export interface SequenceLayout {
+ positions: Map;
+ connectorWaypoints: Map;
+ bounds: Rect;
+}
+
+/**
+ * Pure result of a mutation op (D10). The public canvas API stays
+ * onNodesChange / onEdgesChange; these ops feed the insert/delete wiring
+ * internally.
+ */
+export interface GraphChangeSet {
+ addNodes: Node[];
+ addEdges: Edge[];
+ removeNodeIds: string[];
+ removeEdgeIds: string[];
+}
diff --git a/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.test.ts b/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.test.ts
new file mode 100644
index 000000000..cc6f011b9
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from 'vitest';
+import type { GraphFixture } from './fixtures';
+import {
+ CONTAINER_CHAIN_NODE_IDS,
+ makeContainerChainFixture,
+ makeDiamondFixture,
+ makeWireframeFixture,
+ WIREFRAME_NODE_IDS,
+} from './fixtures';
+import { moveSubtree } from './mutations';
+import { projectSequence } from './projectSequence';
+import type { GraphChangeSet } from './sequential.types';
+import {
+ findIndentSlot,
+ findMoveDownSlot,
+ findMoveUpSlot,
+ findOutdentSlot,
+} from './slotNavigation';
+
+function applyChangeSet(fixture: GraphFixture, changeSet: GraphChangeSet): GraphFixture {
+ const removeNodes = new Set(changeSet.removeNodeIds);
+ const removeEdges = new Set(changeSet.removeEdgeIds);
+ return {
+ nodes: [...fixture.nodes.filter((n) => !removeNodes.has(n.id)), ...changeSet.addNodes],
+ edges: [...fixture.edges.filter((e) => !removeEdges.has(e.id)), ...changeSet.addEdges],
+ };
+}
+
+/**
+ * Node-set-plus-parentId and edge-endpoint (source/target only, NOT handles)
+ * signature. Handles are deliberately excluded: an outdent/indent round-trip
+ * can force a re-splice through a SYNTHETIC slot (no original edge left to
+ * copy a handle from - see slotNavigation.ts's doc comment), which is a
+ * documented best-effort default (`DEFAULT_SOURCE_HANDLE_ID`), not a
+ * connectivity regression. `mutations.test.ts`'s own topologyKey compares
+ * handles too, but its insertAtSlot/removeStep round-trip never hits that
+ * synthetic path (removeStep's heal always copies the real neighboring
+ * handles), so the two helpers are intentionally not identical.
+ */
+function topologyKey(fixture: GraphFixture): string {
+ const nodeKey = fixture.nodes
+ .map((n) => `${n.id}@${n.parentId ?? ''}`)
+ .sort()
+ .join(',');
+ const edgeKey = fixture.edges
+ .map((e) => `${e.source}->${e.target}`)
+ .sort()
+ .join(',');
+ return `${nodeKey} # ${edgeKey}`;
+}
+
+describe('slotNavigation', () => {
+ describe('findMoveUpSlot', () => {
+ it('returns undefined for the very first row', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ expect(findMoveUpSlot(projection, WIREFRAME_NODE_IDS.http)).toBeUndefined();
+ });
+
+ it('returns undefined when the row has no step-connected predecessor (first-in-lane)', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ // "If" is the sole entry of For Each's body: its own incomer is a
+ // branch-entry (container start), not a step - it has no "previous
+ // sibling" to move above.
+ expect(findMoveUpSlot(projection, WIREFRAME_NODE_IDS.ifNode)).toBeUndefined();
+ });
+
+ it('finds the slot that swaps a plain step before its predecessor', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findMoveUpSlot(projection, WIREFRAME_NODE_IDS.javascript);
+ expect(slot).toBeDefined();
+ // http has no predecessor of its own: prepend directly before it
+ // (target-only slot), not a source-based splice.
+ expect(slot?.target?.nodeId).toBe(WIREFRAME_NODE_IDS.http);
+ expect(slot?.source?.nodeId).toBeUndefined();
+
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.javascript, slot!, fixture);
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ const order = movedProjection.rows.filter((r) => r.depth === 0).map((r) => r.nodeId);
+ expect(order.slice(0, 2)).toEqual([WIREFRAME_NODE_IDS.javascript, WIREFRAME_NODE_IDS.http]);
+ });
+ });
+
+ describe('findMoveDownSlot', () => {
+ it('returns undefined for the very last row', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ expect(findMoveDownSlot(projection, WIREFRAME_NODE_IDS.sendMessage)).toBeUndefined();
+ });
+
+ it('finds the slot that swaps a plain step after its successor, including past a container', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findMoveDownSlot(projection, WIREFRAME_NODE_IDS.javascript);
+ expect(slot).toBeDefined();
+ // javascript's successor is the For Each container; moving past it
+ // must land on the container's OWN outgoing seam (its "success" edge
+ // to sendMessage), not its body-start edge.
+ expect(slot?.source?.nodeId).toBe(WIREFRAME_NODE_IDS.forEach);
+ expect(slot?.target?.nodeId).toBe(WIREFRAME_NODE_IDS.sendMessage);
+
+ const changeSet = moveSubtree(projection, WIREFRAME_NODE_IDS.javascript, slot!, fixture);
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ const order = movedProjection.rows.filter((r) => r.depth === 0).map((r) => r.nodeId);
+ expect(order).toEqual([
+ WIREFRAME_NODE_IDS.http,
+ WIREFRAME_NODE_IDS.forEach,
+ WIREFRAME_NODE_IDS.javascript,
+ WIREFRAME_NODE_IDS.sendMessage,
+ ]);
+ });
+
+ it('returns undefined rather than an unsound slot when the next sibling is a bare branch owner', () => {
+ // makeDiamondFixture: A -> If {true: B, false: C} -> D. Moving A "down"
+ // past If would require inserting at If's multi-incomer merge, which is
+ // not a single-edge splice. The former fallback appended a new edge via
+ // If's own source handle, injecting A as a THIRD branch of If and
+ // orphaning it (the corruption browser QA caught). It must disable
+ // instead.
+ const fixture = makeDiamondFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ expect(findMoveDownSlot(projection, 'a')).toBeUndefined();
+ });
+ });
+
+ describe('findOutdentSlot', () => {
+ it('returns undefined for a top-level row', () => {
+ const fixture = makeWireframeFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ expect(findOutdentSlot(projection, WIREFRAME_NODE_IDS.http)).toBeUndefined();
+ });
+
+ it('lands on the container’s own outgoing seam when the owner has a real next step', () => {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const fixture = makeContainerChainFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findOutdentSlot(projection, ids.x);
+ expect(slot?.source?.nodeId).toBe(ids.container);
+ expect(slot?.target?.nodeId).toBe(ids.b);
+ expect(slot?.graphEdgeId).toBe('chain-container-b');
+ });
+ });
+
+ describe('findIndentSlot', () => {
+ it('returns undefined when there is no previous sibling', () => {
+ const fixture = makeContainerChainFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ expect(findIndentSlot(projection, CONTAINER_CHAIN_NODE_IDS.a)).toBeUndefined();
+ });
+
+ it('returns undefined when the previous sibling is not collapsible', () => {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const fixture = makeContainerChainFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ // Y's previous sibling is X, a plain leaf.
+ expect(findIndentSlot(projection, ids.y)).toBeUndefined();
+ });
+
+ it('finds the tail of the preceding container’s linear body', () => {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const fixture = makeContainerChainFixture();
+ const projection = projectSequence(fixture.nodes, fixture.edges);
+ const slot = findIndentSlot(projection, ids.b);
+ expect(slot).toBeDefined();
+ expect(slot?.source?.nodeId).toBe(ids.y); // the body's tail
+ expect(slot?.containerId).toBe(ids.container);
+
+ const changeSet = moveSubtree(projection, ids.b, slot!, fixture);
+ expect(changeSet.removeNodeIds).toEqual([ids.b]);
+ expect(changeSet.addNodes[0]?.parentId).toBe(ids.container);
+
+ const moved = applyChangeSet(fixture, changeSet);
+ const movedProjection = projectSequence(moved.nodes, moved.edges);
+ const containerRow = movedProjection.rows.find((r) => r.nodeId === ids.container);
+ const bRow = movedProjection.rows.find((r) => r.nodeId === ids.b);
+ expect(bRow?.parentRowId).toBe(ids.container);
+ expect(bRow?.depth).toBe((containerRow?.depth ?? 0) + 1);
+ });
+ });
+
+ describe('round-trip: indent then outdent restores topology', () => {
+ it('container chain fixture', () => {
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const base = makeContainerChainFixture();
+ const baseProjection = projectSequence(base.nodes, base.edges);
+
+ const indentSlot = findIndentSlot(baseProjection, ids.b)!;
+ const indented = applyChangeSet(base, moveSubtree(baseProjection, ids.b, indentSlot, base));
+ const indentedProjection = projectSequence(indented.nodes, indented.edges);
+
+ const outdentSlot = findOutdentSlot(indentedProjection, ids.b)!;
+ const restored = applyChangeSet(
+ indented,
+ moveSubtree(indentedProjection, ids.b, outdentSlot, indented)
+ );
+
+ expect(topologyKey(restored)).toBe(topologyKey(base));
+ });
+ });
+});
diff --git a/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.ts b/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.ts
new file mode 100644
index 000000000..d070f3a21
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/sequential/slotNavigation.ts
@@ -0,0 +1,243 @@
+import { DEFAULT_SOURCE_HANDLE_ID } from '../../constants';
+import { ownIncomingConnector, ownOutgoingConnector } from './mutations';
+import type {
+ InsertionSlot,
+ SequenceConnector,
+ SequenceProjection,
+ SequenceRow,
+} from './sequential.types';
+
+/**
+ * Slot navigation for the four file-explorer-like tree operations (move
+ * up/down, indent, outdent) that the view layer (kebab items + Alt+Arrow
+ * keyboard) needs to compute targets for. Every helper here is a pure reader
+ * of an existing {@link SequenceProjection} - none of them mutate anything;
+ * the caller passes the returned slot straight into `moveSubtree` (see its
+ * doc comment in mutations.ts for why `moveSubtree`, not `moveStep`, is the
+ * right op for all four - even a plain leaf crosses a container boundary on
+ * indent/outdent).
+ *
+ * All four helpers return a slot that is EITHER a reference into
+ * `projection.slots` (whenever the target position already has a real edge to
+ * split/extend) or a well-formed synthetic slot built from the same
+ * `{source?, target?, graphEdgeId?, containerId?}` shape `insertAtSlot` and
+ * `moveSubtree` already understand (a source-only slot appends; a
+ * target-only slot prepends) - so `moveSubtree(projection, nodeId, slot,
+ * graph)` works uniformly regardless of which case produced the slot.
+ */
+
+function buildRowsById(projection: SequenceProjection): Map {
+ return new Map(projection.rows.map((row) => [row.nodeId, row]));
+}
+
+/**
+ * The strict lane-internal predecessor/successor relationship: a
+ * `branch-entry` incomer means `nodeId` is the FIRST row of its lane (owned
+ * by, not preceded by, the connector's source), so it must not count as "has
+ * a previous sibling" here - unlike `ownIncomingConnector`, which is
+ * deliberately kind-agnostic for seam-healing purposes elsewhere.
+ *
+ * Excludes `branch-entry` rather than allowlisting `step`, so a container's
+ * own body-exit continuation - reclassified `merge-back` by `projectSequence`
+ * purely for dashed rendering, but still a genuine single-edge spine splice -
+ * is accepted here too. This never widens to a branch lane's OWN merge-back
+ * (into its outer join) or a `goto`: neither ever carries a slot, and both
+ * are filtered out by the `c.slot` check regardless of kind.
+ */
+function stepInto(projection: SequenceProjection, nodeId: string): SequenceConnector | undefined {
+ return projection.connectors.find(
+ (c) => c.kind !== 'branch-entry' && c.targetRowId === nodeId && c.slot
+ );
+}
+
+function stepFrom(projection: SequenceProjection, nodeId: string): SequenceConnector | undefined {
+ return projection.connectors.find(
+ (c) => c.kind !== 'branch-entry' && c.sourceRowId === nodeId && c.slot
+ );
+}
+
+/**
+ * Whether `nodeId` opens one or more branch lanes (it is the source of a
+ * `branch-entry` connector). Such a node's own source handles ARE its branch
+ * outputs, so "append a new edge from this node" splices a fresh lane into the
+ * branch structure rather than continuing past it. Used to keep
+ * {@link findMoveDownSlot} from stepping a node "past" a bare branch owner
+ * whose only exit is a multi-incomer merge, which is not expressible as a
+ * single-edge splice in v1.
+ */
+function sourcesBranchEntry(projection: SequenceProjection, nodeId: string): boolean {
+ return projection.connectors.some((c) => c.kind === 'branch-entry' && c.sourceRowId === nodeId);
+}
+
+/**
+ * The `InsertionSlot` that would place `nodeId` (with its subtree, via
+ * `moveSubtree`) BEFORE its previous visible sibling at the same depth and
+ * lane. Returns `undefined` when `nodeId` is already first in its lane (its
+ * only incomer, if any, is a `branch-entry` - i.e. it IS the lane), or has no
+ * step-connected predecessor at all (e.g. the previous sibling is only
+ * reachable across a `goto`, which carries no slot).
+ *
+ * WARNING: this helper does NOT self-guard against `nodeId` being a bare
+ * branch owner (a row whose only outgoing edges are branch lanes - see
+ * `isBareBranchOwner` in `../../components/SequentialCanvas/sequentialMoveActions.ts`).
+ * For such a node this can still return a slot even though none of the four
+ * move operations are meaningful for it. Callers MUST gate all four
+ * directions (up/down/indent/outdent) on `isBareBranchOwner` first;
+ * `computeSequentialMoveOptions` in that same file is the canonical call site
+ * that does so - do not call this helper directly without that gate.
+ */
+export function findMoveUpSlot(
+ projection: SequenceProjection,
+ nodeId: string
+): InsertionSlot | undefined {
+ const intoNode = stepInto(projection, nodeId);
+ if (!intoNode?.slot) return undefined;
+ const prevId = intoNode.sourceRowId;
+
+ const intoPrev = ownIncomingConnector(projection, prevId);
+ if (intoPrev?.slot) return intoPrev.slot;
+
+ // `prevId` has no predecessor of its own (it is the absolute first row of
+ // the whole sequence, or of a top-level lane with no owner) - prepend
+ // directly before it.
+ return {
+ id: `slot:moveUp:${nodeId}`,
+ target: { nodeId: prevId },
+ containerId: intoNode.slot.containerId,
+ };
+}
+
+/**
+ * The `InsertionSlot` that would place `nodeId` (with its subtree, via
+ * `moveSubtree`) AFTER its next visible sibling at the same depth and lane.
+ * Returns `undefined` when `nodeId` is already last in its lane, or has no
+ * step-connected successor at all (e.g. reachable only across a `goto`).
+ */
+export function findMoveDownSlot(
+ projection: SequenceProjection,
+ nodeId: string
+): InsertionSlot | undefined {
+ const fromNode = stepFrom(projection, nodeId);
+ if (!fromNode?.slot) return undefined;
+ const nextId = fromNode.targetRowId;
+
+ const fromNext = ownOutgoingConnector(projection, nextId);
+ if (fromNext?.slot) return fromNext.slot;
+
+ // `nextId` has no forward spine continuation of its own. If it opens branch
+ // lanes (a bare branch owner like `If`, whose branches rejoin at a
+ // multi-incomer merge), "after nextId" cannot be expressed as a single-edge
+ // splice: appending via nextId's own source handle would add a THIRD lane to
+ // the branch instead of continuing past it (the exact corruption this guards
+ // against). Disable the move rather than corrupt the graph (v1 limitation;
+ // the same class of node the view layer's isBareBranchOwner gate covers).
+ if (sourcesBranchEntry(projection, nextId)) return undefined;
+
+ // `nextId` is a genuine terminal leaf (no outgoing edge at all) - append
+ // directly after it.
+ return {
+ id: `slot:moveDown:${nodeId}`,
+ source: { nodeId: nextId, handleId: DEFAULT_SOURCE_HANDLE_ID },
+ containerId: fromNode.slot.containerId,
+ };
+}
+
+/**
+ * The `InsertionSlot` immediately after the node's owning container/branch
+ * subtree, at the OWNER's own depth (exiting the container body or branch
+ * lane `nodeId` currently sits in - a plain leaf inside a branch lane exits
+ * to the enclosing container's body level, one depth up, not necessarily all
+ * the way to the top). Returns `undefined` when `nodeId` is already top-level
+ * (no owning row at all).
+ *
+ * KNOWN LIMITATION: see `moveSubtree`'s doc comment on container idioms that
+ * close a loop body via an edge back into the container itself - that edge
+ * is invisible here and is not relocated when the body's tail changes.
+ */
+export function findOutdentSlot(
+ projection: SequenceProjection,
+ nodeId: string
+): InsertionSlot | undefined {
+ const rows = buildRowsById(projection);
+ const ownerId = rows.get(nodeId)?.parentRowId;
+ if (ownerId === undefined) return undefined;
+
+ const outgoing = ownOutgoingConnector(projection, ownerId);
+ if (outgoing?.slot) return outgoing.slot;
+
+ // The owner has no genuine forward step of its own (it is the last thing at
+ // its own level) - append directly after it, in ITS OWN scope (read off its
+ // own incoming slot, since incoming/outgoing edges of the same node always
+ // share the same graph-level `containerId`).
+ const incoming = ownIncomingConnector(projection, ownerId);
+ return {
+ id: `slot:outdent:${ownerId}`,
+ source: { nodeId: ownerId, handleId: DEFAULT_SOURCE_HANDLE_ID },
+ containerId: incoming?.slot?.containerId,
+ };
+}
+
+/**
+ * The `InsertionSlot` at the TAIL of the immediately-preceding visible
+ * sibling's body, when that sibling is a container or branch owner
+ * (file-explorer "move into the folder above"). Returns `undefined` when
+ * there is no previous sibling, or it is not `collapsible`. For an owner with
+ * multiple lanes (a multi-entry container body, or a multi-way branch), the
+ * FIRST lane's tail is used (first connector in projection order, which
+ * mirrors `entriesForScope`'s flow-y ordering / the branch's out-edge order).
+ *
+ * WARNING: this helper does NOT self-guard against `nodeId` itself being a
+ * bare branch owner (a row whose only outgoing edges are branch lanes - see
+ * `isBareBranchOwner` in `../../components/SequentialCanvas/sequentialMoveActions.ts`).
+ * It only checks the PRECEDING sibling's `collapsible` flag, not whether
+ * `nodeId` is one. Callers MUST gate all four directions
+ * (up/down/indent/outdent) on `isBareBranchOwner` first;
+ * `computeSequentialMoveOptions` in that same file is the canonical call site
+ * that does so - do not call this helper directly without that gate.
+ */
+export function findIndentSlot(
+ projection: SequenceProjection,
+ nodeId: string
+): InsertionSlot | undefined {
+ const intoNode = stepInto(projection, nodeId);
+ if (!intoNode) return undefined;
+ const prevId = intoNode.sourceRowId;
+
+ const rows = buildRowsById(projection);
+ const prevRow = rows.get(prevId);
+ if (!prevRow?.collapsible) return undefined;
+
+ const firstEntry = projection.connectors.find(
+ (c) => c.kind === 'branch-entry' && c.sourceRowId === prevId
+ );
+ if (!firstEntry?.slot) {
+ // A container with a genuinely empty body never emits a branch-entry
+ // connector at all (see projectSequence's walkContainerBody); its own
+ // registered empty-body slot IS the tail.
+ return projection.slots.find(
+ (slot) => slot.containerId === prevId && slot.source?.nodeId === prevId && !slot.target
+ );
+ }
+
+ const firstRow = rows.get(firstEntry.targetRowId);
+ if (!firstRow || firstRow.parentRowId !== prevId) {
+ // An empty lane (the branch dead-ends straight into the merge/outer
+ // node, with no row of its own inside the lane): the branch-entry
+ // connector's own slot IS the tail - splicing there makes the indented
+ // node the lane's sole content.
+ return firstEntry.slot;
+ }
+
+ let tailId = firstRow.nodeId;
+ for (;;) {
+ const next = stepFrom(projection, tailId);
+ if (!next) break;
+ tailId = next.targetRowId;
+ }
+
+ return {
+ id: `slot:indentTail:${prevId}:${tailId}`,
+ source: { nodeId: tailId, handleId: DEFAULT_SOURCE_HANDLE_ID },
+ containerId: firstEntry.slot.containerId,
+ };
+}
diff --git a/packages/apollo-react/src/canvas/utils/workflow-layout.test.ts b/packages/apollo-react/src/canvas/utils/workflow-layout.test.ts
new file mode 100644
index 000000000..ff2c68b47
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/workflow-layout.test.ts
@@ -0,0 +1,129 @@
+import type { Edge, Node } from '@uipath/apollo-react/canvas/xyflow/react';
+import { describe, expect, it } from 'vitest';
+import {
+ CONTAINER_CHAIN_NODE_IDS,
+ makeContainerChainFixture,
+ makeCycleFixture,
+ makeDiamondFixture,
+} from './sequential/fixtures';
+import { layoutWorkflowLeftToRight } from './workflow-layout';
+
+const size = { width: 100, height: 60 };
+const fixedSize = () => size;
+
+describe('layoutWorkflowLeftToRight', () => {
+ it('places a linear workflow in dependency order from left to right', () => {
+ const nodes = [node('a'), node('b'), node('c')];
+ const edges = [edge('a', 'b'), edge('b', 'c')];
+
+ const result = layoutWorkflowLeftToRight(nodes, edges, {
+ rankGap: 40,
+ getNodeDimensions: fixedSize,
+ });
+
+ expect(result.positions.get('a')).toEqual({ x: 0, y: 0 });
+ expect(result.positions.get('b')).toEqual({ x: 140, y: 0 });
+ expect(result.positions.get('c')).toEqual({ x: 280, y: 0 });
+ expect(result.bounds).toEqual({ x: 0, y: 0, width: 380, height: 60 });
+ expect(nodes.map((item) => item.position)).toEqual([
+ { x: 0, y: 0 },
+ { x: 0, y: 0 },
+ { x: 0, y: 0 },
+ ]);
+ });
+
+ it('stacks branch lanes and puts their merge in a later rank', () => {
+ const { nodes, edges } = makeDiamondFixture();
+ const result = layoutWorkflowLeftToRight(nodes, edges, {
+ rankGap: 40,
+ nodeGap: 20,
+ getNodeDimensions: fixedSize,
+ });
+
+ const branch = result.positions.get('if')!;
+ const thenNode = result.positions.get('b')!;
+ const elseNode = result.positions.get('c')!;
+ const merge = result.positions.get('d')!;
+
+ expect(thenNode.x).toBeGreaterThan(branch.x);
+ expect(elseNode.x).toBe(thenNode.x);
+ expect(elseNode.y).toBeGreaterThanOrEqual(thenNode.y + size.height + 20);
+ expect(merge.x).toBeGreaterThan(thenNode.x);
+ });
+
+ it('collapses cycles into one finite, deterministic rank', () => {
+ const fixture = makeCycleFixture();
+ const first = layoutWorkflowLeftToRight(fixture.nodes, fixture.edges, {
+ nodeGap: 20,
+ getNodeDimensions: fixedSize,
+ });
+ const second = layoutWorkflowLeftToRight(fixture.nodes, fixture.edges, {
+ nodeGap: 20,
+ getNodeDimensions: fixedSize,
+ });
+
+ expect([...first.positions]).toEqual([...second.positions]);
+ expect(new Set([...first.positions.values()].map((position) => position.x))).toEqual(
+ new Set([0])
+ );
+ expect([...first.positions.values()].map((position) => position.y)).toEqual([0, 80, 160]);
+ });
+
+ it('lays out children in the parent coordinate frame and grows the container', () => {
+ const fixture = makeContainerChainFixture();
+ const ids = CONTAINER_CHAIN_NODE_IDS;
+ const result = layoutWorkflowLeftToRight(fixture.nodes, fixture.edges, {
+ rankGap: 40,
+ getNodeDimensions: fixedSize,
+ isContainerNode: (item) => item.id === ids.container,
+ });
+
+ const containerSize = result.dimensions.get(ids.container)!;
+ const firstChild = result.positions.get(ids.x)!;
+ const secondChild = result.positions.get(ids.y)!;
+ const container = result.positions.get(ids.container)!;
+ const successor = result.positions.get(ids.b)!;
+
+ expect(firstChild.x).toBeGreaterThan(0);
+ expect(firstChild.y).toBeGreaterThan(0);
+ expect(secondChild.x).toBeGreaterThan(firstChild.x);
+ expect(containerSize.width).toBeGreaterThanOrEqual(secondChild.x + size.width);
+ expect(containerSize.height).toBeGreaterThanOrEqual(firstChild.y + size.height);
+ expect(successor.x).toBeGreaterThanOrEqual(container.x + containerSize.width + 40);
+ expect(result.resizedNodeIds).toEqual(new Set([ids.container]));
+ });
+
+ it('preserves presentation-only nodes by omitting them from the layout result', () => {
+ const nodes = [node('a'), node('note', 'sticky', { x: 900, y: 700 }), node('b')];
+ const edges = [edge('a', 'b')];
+ const result = layoutWorkflowLeftToRight(nodes, edges, {
+ getNodeDimensions: fixedSize,
+ isLayoutNode: (item) => item.type !== 'sticky',
+ });
+
+ expect(result.positions.has('note')).toBe(false);
+ expect(nodes[1]!.position).toEqual({ x: 900, y: 700 });
+ expect(result.positions.get('b')!.x).toBeGreaterThan(result.positions.get('a')!.x);
+ });
+
+ it('accounts for non-default node origins when returning XYFlow positions', () => {
+ const centered = node('centered');
+ centered.origin = [0.5, 0.5];
+
+ const result = layoutWorkflowLeftToRight([centered], [], {
+ origin: { x: 20, y: 30 },
+ getNodeDimensions: fixedSize,
+ });
+
+ expect(result.positions.get('centered')).toEqual({ x: 70, y: 60 });
+ expect(result.bounds).toEqual({ x: 20, y: 30, width: 100, height: 60 });
+ });
+});
+
+function node(id: string, type = 'task', position = { x: 0, y: 0 }): Node {
+ return { id, type, position, data: {} };
+}
+
+function edge(source: string, target: string): Edge {
+ return { id: `${source}-${target}`, source, target };
+}
diff --git a/packages/apollo-react/src/canvas/utils/workflow-layout.ts b/packages/apollo-react/src/canvas/utils/workflow-layout.ts
new file mode 100644
index 000000000..d45073c26
--- /dev/null
+++ b/packages/apollo-react/src/canvas/utils/workflow-layout.ts
@@ -0,0 +1,319 @@
+import type { Edge, Node, XYPosition } from '@uipath/apollo-react/canvas/xyflow/react';
+import { GRID_SPACING } from '../constants';
+import { getContainerFitGeometry, getNodeDimensions, type NodeDimensions } from './container';
+
+export interface WorkflowLayoutOptions {
+ /** Distance between dependency ranks. */
+ rankGap?: number;
+ /** Vertical distance between nodes in the same rank. */
+ nodeGap?: number;
+ /** Top-left origin for the root scope. */
+ origin?: XYPosition;
+ /** Identifies empty containers; containers with children are inferred automatically. */
+ isContainerNode?: (node: Node) => boolean;
+ /** Excludes presentation-only nodes while preserving their canonical positions. */
+ isLayoutNode?: (node: Node) => boolean;
+ /** Excludes presentation-only edges and loop-closing edges from rank calculation. */
+ isLayoutEdge?: (edge: Edge) => boolean;
+ /** Overrides measured node dimensions. */
+ getNodeDimensions?: (node: Node) => NodeDimensions;
+}
+
+export interface WorkflowLayoutBounds extends XYPosition, NodeDimensions {}
+
+/**
+ * A complete, immutable left-to-right presentation of the canonical workflow.
+ * Positions remain relative to `parentId`, matching XYFlow's node contract.
+ */
+export interface WorkflowLayoutResult {
+ positions: ReadonlyMap;
+ dimensions: ReadonlyMap;
+ /** Nodes whose outer dimensions must be applied with the layout (containers only). */
+ resizedNodeIds: ReadonlySet;
+ bounds: WorkflowLayoutBounds;
+}
+
+interface ScopeLayout {
+ width: number;
+ height: number;
+}
+
+interface LayoutItem {
+ node: Node;
+ size: NodeDimensions;
+ stableIndex: number;
+}
+
+const DEFAULT_RANK_GAP = GRID_SPACING * 5;
+const DEFAULT_NODE_GAP = GRID_SPACING * 3;
+
+/**
+ * Computes a deterministic left-to-right layout without changing graph data.
+ *
+ * Each containment scope is laid out independently. Nested scopes are resolved
+ * inside-out so a container's calculated footprint participates in its parent's
+ * rank spacing. Strongly connected components share a rank, which means cyclic
+ * graphs remain finite and deterministic rather than hanging an auto-layout pass.
+ */
+export function layoutWorkflowLeftToRight(
+ nodes: Node[],
+ edges: Edge[],
+ options: WorkflowLayoutOptions = {}
+): WorkflowLayoutResult {
+ const rankGap = options.rankGap ?? DEFAULT_RANK_GAP;
+ const nodeGap = options.nodeGap ?? DEFAULT_NODE_GAP;
+ const rootOrigin = options.origin ?? { x: 0, y: 0 };
+ const shouldLayoutNode = options.isLayoutNode ?? (() => true);
+ const shouldLayoutEdge =
+ options.isLayoutEdge ?? ((edge: Edge) => edge.targetHandle !== 'loopBack');
+ const resolveDimensions = options.getNodeDimensions ?? getNodeDimensions;
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
+ const positions = new Map();
+ const dimensions = new Map();
+ const resizedNodeIds = new Set();
+ const childrenByParent = new Map();
+
+ for (const node of nodes) {
+ const parentId = node.parentId && nodeById.has(node.parentId) ? node.parentId : undefined;
+ const siblings = childrenByParent.get(parentId) ?? [];
+ siblings.push(node);
+ childrenByParent.set(parentId, siblings);
+ }
+
+ const isContainer = (node: Node) =>
+ (childrenByParent.get(node.id)?.length ?? 0) > 0 || options.isContainerNode?.(node) === true;
+
+ const layoutScope = (parentId: string | undefined): ScopeLayout => {
+ const directChildren = (childrenByParent.get(parentId) ?? []).filter(shouldLayoutNode);
+ const items: LayoutItem[] = directChildren.map((node, stableIndex) => {
+ let size = resolveDimensions(node);
+ const nestedChildren = childrenByParent.get(node.id)?.filter(shouldLayoutNode) ?? [];
+
+ if (isContainer(node)) {
+ resizedNodeIds.add(node.id);
+ const nested = layoutScope(node.id);
+ const fit = getContainerFitGeometry();
+ const padding = fit.padding ?? {};
+ const left = padding.left ?? 0;
+ const top = padding.top ?? 0;
+ const requiredWidth = left + nested.width + (padding.right ?? 0);
+ const requiredHeight = top + nested.height + (padding.bottom ?? 0);
+ size = {
+ width: Math.max(size.width, fit.minWidth, requiredWidth),
+ height: Math.max(size.height, fit.minHeight, requiredHeight),
+ };
+
+ if (nestedChildren.length > 0) {
+ for (const child of nestedChildren) {
+ const childPosition = positions.get(child.id);
+ if (childPosition) {
+ positions.set(child.id, {
+ x: childPosition.x + left,
+ y: childPosition.y + top,
+ });
+ }
+ }
+ }
+ }
+
+ dimensions.set(node.id, size);
+ return { node, size, stableIndex };
+ });
+
+ if (items.length === 0) return { width: 0, height: 0 };
+
+ const itemById = new Map(items.map((item) => [item.node.id, item]));
+ const scopedEdges = edges.filter(
+ (edge) => shouldLayoutEdge(edge) && itemById.has(edge.source) && itemById.has(edge.target)
+ );
+ const ranks = assignRanks(items, scopedEdges);
+ const itemsByRank = new Map();
+
+ for (const item of items) {
+ const rank = ranks.get(item.node.id) ?? 0;
+ const rankItems = itemsByRank.get(rank) ?? [];
+ rankItems.push(item);
+ itemsByRank.set(rank, rankItems);
+ }
+
+ const sortedRanks = [...itemsByRank.keys()].sort((left, right) => left - right);
+ let x = 0;
+ let scopeWidth = 0;
+ let scopeHeight = 0;
+
+ for (const rank of sortedRanks) {
+ const rankItems = itemsByRank.get(rank)!;
+ rankItems.sort(compareStableItems);
+ const columnWidth = Math.max(...rankItems.map((item) => item.size.width));
+ const columnHeight =
+ rankItems.reduce((total, item) => total + item.size.height, 0) +
+ nodeGap * Math.max(0, rankItems.length - 1);
+ let y = 0;
+
+ for (const item of rankItems) {
+ const topLeft = { x, y };
+ positions.set(item.node.id, topLeftToNodePosition(topLeft, item.node, item.size));
+ y += item.size.height + nodeGap;
+ }
+
+ scopeWidth = Math.max(scopeWidth, x + columnWidth);
+ scopeHeight = Math.max(scopeHeight, columnHeight);
+ x += columnWidth + rankGap;
+ }
+
+ return { width: scopeWidth, height: scopeHeight };
+ };
+
+ const rootBounds = layoutScope(undefined);
+ if (rootOrigin.x !== 0 || rootOrigin.y !== 0) {
+ for (const rootNode of childrenByParent.get(undefined) ?? []) {
+ const position = positions.get(rootNode.id);
+ if (position) {
+ positions.set(rootNode.id, {
+ x: position.x + rootOrigin.x,
+ y: position.y + rootOrigin.y,
+ });
+ }
+ }
+ }
+
+ return {
+ positions,
+ dimensions,
+ resizedNodeIds,
+ bounds: { ...rootOrigin, ...rootBounds },
+ };
+}
+
+function compareStableItems(left: LayoutItem, right: LayoutItem): number {
+ return (
+ left.node.position.y - right.node.position.y ||
+ left.node.position.x - right.node.position.x ||
+ left.stableIndex - right.stableIndex ||
+ left.node.id.localeCompare(right.node.id)
+ );
+}
+
+function topLeftToNodePosition(topLeft: XYPosition, node: Node, size: NodeDimensions): XYPosition {
+ const [originX = 0, originY = 0] = node.origin ?? [];
+ return {
+ x: topLeft.x + size.width * originX,
+ y: topLeft.y + size.height * originY,
+ };
+}
+
+/** Assigns longest-path ranks after collapsing strongly connected components. */
+function assignRanks(items: LayoutItem[], edges: Edge[]): Map {
+ const ids = items.map((item) => item.node.id);
+ const order = new Map(ids.map((id, index) => [id, index]));
+ const outgoing = new Map(ids.map((id) => [id, [] as string[]]));
+
+ for (const edge of edges) {
+ if (edge.source !== edge.target && !outgoing.get(edge.source)!.includes(edge.target)) {
+ outgoing.get(edge.source)!.push(edge.target);
+ }
+ }
+ for (const targets of outgoing.values()) {
+ targets.sort((left, right) => order.get(left)! - order.get(right)!);
+ }
+
+ const components = findStronglyConnectedComponents(ids, outgoing);
+ const componentById = new Map();
+ components.forEach((component, componentIndex) => {
+ for (const id of component) componentById.set(id, componentIndex);
+ });
+
+ const componentOutgoing = new Map>();
+ const indegree = new Map();
+ components.forEach((_component, index) => {
+ componentOutgoing.set(index, new Set());
+ indegree.set(index, 0);
+ });
+
+ for (const [source, targets] of outgoing) {
+ const sourceComponent = componentById.get(source)!;
+ for (const target of targets) {
+ const targetComponent = componentById.get(target)!;
+ if (
+ sourceComponent !== targetComponent &&
+ !componentOutgoing.get(sourceComponent)!.has(targetComponent)
+ ) {
+ componentOutgoing.get(sourceComponent)!.add(targetComponent);
+ indegree.set(targetComponent, indegree.get(targetComponent)! + 1);
+ }
+ }
+ }
+
+ const componentOrder = components.map((component) =>
+ Math.min(...component.map((id) => order.get(id)!))
+ );
+ const ready = components
+ .map((_component, index) => index)
+ .filter((index) => indegree.get(index) === 0)
+ .sort((left, right) => componentOrder[left]! - componentOrder[right]!);
+ const componentRanks = new Map();
+
+ while (ready.length > 0) {
+ const component = ready.shift()!;
+ const rank = componentRanks.get(component) ?? 0;
+ const targets = [...componentOutgoing.get(component)!].sort(
+ (left, right) => componentOrder[left]! - componentOrder[right]!
+ );
+
+ for (const target of targets) {
+ componentRanks.set(target, Math.max(componentRanks.get(target) ?? 0, rank + 1));
+ indegree.set(target, indegree.get(target)! - 1);
+ if (indegree.get(target) === 0) {
+ ready.push(target);
+ ready.sort((left, right) => componentOrder[left]! - componentOrder[right]!);
+ }
+ }
+ }
+
+ return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]));
+}
+
+function findStronglyConnectedComponents(
+ ids: string[],
+ outgoing: ReadonlyMap
+): string[][] {
+ let nextIndex = 0;
+ const indexById = new Map();
+ const lowLinkById = new Map();
+ const stack: string[] = [];
+ const onStack = new Set();
+ const components: string[][] = [];
+
+ const visit = (id: string) => {
+ indexById.set(id, nextIndex);
+ lowLinkById.set(id, nextIndex);
+ nextIndex += 1;
+ stack.push(id);
+ onStack.add(id);
+
+ for (const target of outgoing.get(id) ?? []) {
+ if (!indexById.has(target)) {
+ visit(target);
+ lowLinkById.set(id, Math.min(lowLinkById.get(id)!, lowLinkById.get(target)!));
+ } else if (onStack.has(target)) {
+ lowLinkById.set(id, Math.min(lowLinkById.get(id)!, indexById.get(target)!));
+ }
+ }
+
+ if (lowLinkById.get(id) !== indexById.get(id)) return;
+ const component: string[] = [];
+ let member: string;
+ do {
+ member = stack.pop()!;
+ onStack.delete(member);
+ component.push(member);
+ } while (member !== id);
+ components.push(component);
+ };
+
+ for (const id of ids) {
+ if (!indexById.has(id)) visit(id);
+ }
+
+ return components;
+}
diff --git a/packages/apollo-react/src/test/canvas-mocks.ts b/packages/apollo-react/src/test/canvas-mocks.ts
index ab98fb357..82c8a6b2a 100644
--- a/packages/apollo-react/src/test/canvas-mocks.ts
+++ b/packages/apollo-react/src/test/canvas-mocks.ts
@@ -193,6 +193,8 @@ vi.mock('@uipath/apollo-react/canvas/xyflow/react', () => ({
}),
ViewportPortal: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'viewport-portal' }, children),
+ EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'edge-label-renderer' }, children),
addEdge: vi.fn((connection, edges) => [...edges, { id: 'new-edge', ...connection }]),
applyEdgeChanges: vi.fn((changes: any[], edges: any[]) => {
return edges.map((edge: any) => {