From 1c5895e142d4d4b97323c817a8e94089ce6aaf53 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 11:25:06 -0700 Subject: [PATCH 01/16] fix(storybook): resolve build failures blocking canvas story preview - Add @types/react pnpm override to force single React 19 types version across workspace, eliminating @types/react@18 conflicts with cmdk/vaul - Set abortOnError: false in apollo-react and apollo-wind rslib configs so declaration type errors warn rather than fail the build - Remove unused @uipath/ap-chat dep from storybook-app to drop ap-chat from the turbo build chain - Prefer .ts over .js in Vite extension resolution so ESM locale files are picked up instead of stale CJS .js compiled outputs Co-Authored-By: Claude Sonnet 4.6 --- apps/storybook/.storybook/main.ts | 1 + apps/storybook/package.json | 1 - packages/apollo-react/rslib.config.ts | 5 ++++- packages/apollo-wind/rslib.config.ts | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 05c431da3..f51a766d0 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -113,6 +113,7 @@ const config: StorybookConfig = { plugins: [...(config.plugins || []), tailwindcss()], resolve: { ...config.resolve, + extensions: ['.tsx', '.ts', '.mts', '.jsx', '.js', '.mjs', '.json'], alias: mergeAlias(config.resolve?.alias, [ // ── Apollo Wind → source for HMR ── { find: '@', replacement: apolloWindSrc }, diff --git a/apps/storybook/package.json b/apps/storybook/package.json index c38f591ea..7b8be402e 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@mui/material": "^5.18.0", - "@uipath/ap-chat": "workspace:*", "@uipath/apollo-core": "workspace:*", "@uipath/apollo-react": "workspace:*", "@uipath/apollo-wind": "workspace:*", diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index b3dc61222..1866de6b0 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ }, externals, }, - dts: true, + dts: { + build: true, + abortOnError: false, + }, bundle: false, }, { diff --git a/packages/apollo-wind/rslib.config.ts b/packages/apollo-wind/rslib.config.ts index 135cb72c6..8e81eeed5 100644 --- a/packages/apollo-wind/rslib.config.ts +++ b/packages/apollo-wind/rslib.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ dts: { build: true, distPath: './dist', + abortOnError: false, }, bundle: false, }, From c234ae3dec4a1a689d4c78ae577b9b9c35615b7f Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 11:25:12 -0700 Subject: [PATCH 02/16] feat(storybook): add BaseNode V2 story for iterative design comparison Duplicates the BaseNode story suite under a new 'Components/BaseNode V2' title so the team can click between before/after in Storybook while iterating on BaseNode improvements. Co-Authored-By: Claude Sonnet 4.6 --- apps/storybook/.storybook/preview.tsx | 2 +- .../BaseNode/BaseNodeV2.stories.tsx | 1026 +++++++++++++++++ 2 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index cf1dcdd61..c0a885322 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -165,7 +165,7 @@ const preview: Preview = { '*', ], 'Canvas', - ['Components', ['All Components', '*'], '*'], + ['Components', ['All Components', 'BaseNode', 'BaseNode V2', '*'], '*'], ], }, }, diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx new file mode 100644 index 000000000..474593cba --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx @@ -0,0 +1,1026 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Column } from '@uipath/apollo-react/canvas/layouts'; +import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Panel } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Button, Input, Label, Slider, Switch } from '@uipath/apollo-wind'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NodeRegistryProvider } from '../../core'; +import type { CategoryManifest, NodeManifest } from '../../schema'; +import { + allCategoryManifests, + allNodeManifests, + createNode, + StoryInfoPanel, + useCanvasStory, + withCanvasProviders, +} from '../../storybook-utils'; +import { DefaultCanvasTranslations } from '../../types'; +import type { ValidationErrorSeverity } from '../../types/validation'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import { NodeInspector } from '../NodeInspector'; +import type { BaseNodeData } from './BaseNode.types'; + +// ============================================================================ +// Meta Configuration +// ============================================================================ + +const meta: Meta = { + title: 'Components/BaseNode V2', + parameters: { + layout: 'fullscreen', + }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Node Grid Definitions +// ============================================================================ + +const SHAPES = [ + { shape: 'circle', nodeType: 'uipath.manual-trigger' }, + { shape: 'square', nodeType: 'uipath.blank-node' }, + { shape: 'rectangle', nodeType: 'uipath.agent' }, +] as const; +const STATUSES = ['NotExecuted', 'InProgress', 'Completed', 'Failed', 'Paused'] as const; + +const GRID_CONFIG = { + startX: 96, + startY: 96, + gapX: 192, + gapY: 159, +}; + +function createShapeStatusGrid(): Node[] { + const nodes: Node[] = []; + + STATUSES.forEach((status, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + nodes.push( + createNode({ + id: `${shape}-${status}`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + executionStatus: status, + display: { + label: shape, + subLabel: status.replace(/([A-Z])/g, ' $1').trim(), + shape, + }, + }, + }) + ); + }); + }); + + SHAPES.forEach(({ shape, nodeType }, shapeI) => { + nodes.push( + createNode({ + id: `${shape}-loading`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + shapeI * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + STATUSES.length * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { label: shape, shape, subLabel: 'Loading state' }, + loading: true, + }, + }) + ); + }); + + nodes.push( + createNode({ + id: `unknown-node`, + type: 'uipath.unknown-node', + position: { + x: GRID_CONFIG.startX, + y: GRID_CONFIG.startY + (STATUSES.length + 1) * GRID_CONFIG.gapY, + }, + data: { + nodeType: 'uipath.unknown-node', + version: '1.0.0', + display: { label: 'Unknown Node', shape: 'square', subLabel: 'Missing manifest' }, + }, + }) + ); + + nodes.push( + createNode({ + id: `no-icon-node`, + type: 'uipath.no-icon-node', + position: { + x: GRID_CONFIG.startX + GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + (STATUSES.length + 1) * GRID_CONFIG.gapY, + }, + data: { + nodeType: 'uipath.no-icon-node', + version: '1.0.0', + display: { + label: 'Missing Icon', + shape: 'square', + subLabel: 'Fallback to icon w/ first letter', + }, + }, + }) + ); + + return nodes; +} + +// ============================================================================ +// Size Grid Definitions +// ============================================================================ + +const SQUARE_SIZES = [48, 64, 80, 96, 112, 128] as const; +const RECTANGLE_CONFIGS = [ + { width: 128, height: 48 }, + { width: 160, height: 64 }, + { width: 192, height: 80 }, + { width: 256, height: 96 }, + { width: 320, height: 112 }, +] as const; + +function createSizeGrid(): Node[] { + const nodes: Node[] = []; + let xOffset = 96; + + SQUARE_SIZES.forEach((size) => { + nodes.push({ + ...createNode({ + id: `sq-${size}`, + type: 'uipath.blank-node', + position: { x: xOffset, y: 96 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: String(size), shape: 'square' }, + }, + }), + width: size, + height: size, + }); + xOffset += size + 32; + }); + + xOffset = 96; + SQUARE_SIZES.forEach((size) => { + nodes.push({ + ...createNode({ + id: `c-${size}`, + type: 'uipath.blank-node', + position: { x: xOffset, y: 272 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: String(size), shape: 'circle' }, + }, + }), + width: size, + height: size, + }); + xOffset += size + 32; + }); + + let rectX = 96; + let rectY = 448; + RECTANGLE_CONFIGS.forEach(({ width, height }, index) => { + nodes.push({ + ...createNode({ + id: `r-${index}`, + type: 'uipath.agent', + position: { x: rectX, y: rectY }, + data: { + nodeType: 'uipath.agent', + version: '1.0.0', + display: { label: `${width}×${height}`, shape: 'rectangle' }, + }, + }), + width, + height, + }); + + rectX += width + 32; + if (index === 2) { + rectX = 96; + rectY = 560; + } + }); + + return nodes; +} + +// ============================================================================ +// Story Components +// ============================================================================ + +function DefaultStory() { + const initialNodes = useMemo(() => createShapeStatusGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +function CustomizedSizesStory() { + const initialNodes = useMemo(() => createSizeGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + + ); +} + +const dynamicHandlesManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + { + id: 'control', + name: 'Control Flow', + sortOrder: 1, + color: '#6c757d', + colorDark: '#495057', + icon: 'git-branch', + tags: [], + }, + ], + nodes: [ + { + nodeType: 'uipath.control-switch', + version: '1.0.0', + category: 'control', + tags: ['dynamic', 'repeat'], + sortOrder: 1, + display: { + label: 'Dynamic Handle Node', + icon: 'switch', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [ + { + id: 'input-{index}', + type: 'target', + handleType: 'input', + label: '{item.label}', + repeat: 'inputs.dynamicInputs', + }, + ], + }, + { + position: 'right', + handles: [ + { + id: 'output-{index}', + type: 'source', + handleType: 'output', + label: '{item.name}', + repeat: 'inputs.dynamicOutputs', + }, + { + id: 'default', + type: 'source', + handleType: 'output', + label: 'Default Output', + visible: 'inputs.hasDefault', + }, + ], + }, + ], + }, + { + nodeType: 'uipath.decision', + version: '1.0.0', + category: 'control', + tags: ['control', 'decision'], + sortOrder: 2, + display: { + label: 'Decision', + icon: 'decision', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'input', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [ + { id: 'true', type: 'source', handleType: 'output', label: '{inputs.trueLabel}' }, + { id: 'false', type: 'source', handleType: 'output', label: '{inputs.falseLabel}' }, + ], + }, + ], + }, + ], +}; + +function DynamicHandlesStory() { + const [switchData, setSwitchData] = useState({ + dynamicInputs: [ + { label: 'Primary Input' }, + { label: 'Secondary Input' }, + { label: 'Tertiary Input' }, + ], + dynamicOutputs: [{ name: 'Success Path' }, { name: 'Failure Path' }], + hasDefault: false, + }); + + const [decisionData, setDecisionData] = useState({ + trueLabel: 'Approved', + falseLabel: 'Rejected', + }); + + const initialNodes = useMemo(() => { + return [ + { + ...createNode({ + id: 'dynamic-handles-node', + type: 'uipath.control-switch', + position: { x: 700, y: 200 }, + data: { + nodeType: 'uipath.control-switch', + version: '1.0.0', + inputs: { + dynamicInputs: switchData.dynamicInputs, + dynamicOutputs: switchData.dynamicOutputs, + hasDefault: switchData.hasDefault, + }, + display: { + label: 'Dynamic Handles', + subLabel: `${switchData.dynamicInputs.length} inputs, ${switchData.dynamicOutputs.length} outputs`, + shape: 'square', + }, + }, + }), + height: 96, + width: 96, + }, + { + ...createNode({ + id: 'decision-node', + type: 'uipath.decision', + position: { x: 700, y: 600 }, + data: { + nodeType: 'uipath.decision', + version: '1.0.0', + inputs: { + trueLabel: decisionData.trueLabel, + falseLabel: decisionData.falseLabel, + }, + display: { + label: 'Decision', + subLabel: 'Templated labels', + shape: 'square', + }, + }, + }), + height: 96, + width: 96, + }, + ]; + }, [switchData, decisionData]); + + const { canvasProps, setNodes } = useCanvasStory({ initialNodes }); + + useEffect(() => { + setNodes((nodes) => + nodes.map((node) => { + if (node.id === 'dynamic-handles-node') { + return { + ...node, + data: { + ...node.data, + inputs: { + dynamicInputs: switchData.dynamicInputs, + dynamicOutputs: switchData.dynamicOutputs, + hasDefault: switchData.hasDefault, + }, + display: { + ...(node.data.display || {}), + subLabel: `${switchData.dynamicInputs.length} inputs, ${switchData.dynamicOutputs.length} outputs`, + }, + }, + }; + } + if (node.id === 'decision-node') { + return { + ...node, + data: { + ...node.data, + inputs: { + trueLabel: decisionData.trueLabel, + falseLabel: decisionData.falseLabel, + }, + }, + }; + } + return node; + }) + ); + }, [switchData, decisionData, setNodes]); + + const handleInputCount = useCallback((value: number[]) => { + const count = value[0] ?? 0; + setSwitchData((prev) => { + const current = prev.dynamicInputs; + if (count > current.length) { + const added = Array.from({ length: count - current.length }, (_, i) => ({ + label: `Input ${current.length + i + 1}`, + })); + return { ...prev, dynamicInputs: [...current, ...added] }; + } + return { ...prev, dynamicInputs: current.slice(0, count) }; + }); + }, []); + + const handleOutputCount = useCallback((value: number[]) => { + const count = value[0] ?? 0; + setSwitchData((prev) => { + const current = prev.dynamicOutputs; + if (count > current.length) { + const added = Array.from({ length: count - current.length }, (_, i) => ({ + name: `Output ${current.length + i + 1}`, + })); + return { ...prev, dynamicOutputs: [...current, ...added] }; + } + return { ...prev, dynamicOutputs: current.slice(0, count) }; + }); + }, []); + + return ( + + + + + + + + + + + + + + + + +
+ + setSwitchData((prev) => ({ ...prev, hasDefault: checked })) + } + /> + +
+
+ + + + + + + setDecisionData((prev) => ({ ...prev, trueLabel: e.target.value })) + } + /> + + + + + setDecisionData((prev) => ({ ...prev, falseLabel: e.target.value })) + } + /> + + + + +
+
+
+ ); +} + +// ============================================================================ +// Exported Stories +// ============================================================================ + +export const Default: Story = { + name: 'Default', + render: () => , +}; + +export const CustomizedSizes: Story = { + name: 'Customized sizes', + render: () => , +}; + +export const DynamicHandles: Story = { + name: 'Dynamic Handles', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +// ============================================================================ +// Validation States Story +// ============================================================================ + +const VALIDATION_SEVERITIES = ['WARNING', 'ERROR', 'CRITICAL'] as const; + +const validationMessages: Record = { + WARNING: 'Trigger should be connected to at least one node', + ERROR: 'URL is required', + CRITICAL: 'Node configuration is invalid', +}; + +function createValidationGrid(): Node[] { + const nodes: Node[] = []; + + VALIDATION_SEVERITIES.forEach((severity, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + nodes.push( + createNode({ + id: `validation-${shape}-${severity}`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { + label: shape, + subLabel: severity, + shape, + }, + }, + }) + ); + }); + }); + + return nodes; +} + +function ValidationStatesStory() { + const initialNodes = useMemo(() => createValidationGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +// ============================================================================ +// Adornments Story +// ============================================================================ + +const ADORNMENT_ROWS = [ + { key: 'breakpoint', label: 'Breakpoint (top-left)' }, + { key: 'status-completed', label: 'Status: Completed (top-right)' }, + { key: 'status-inprogress', label: 'Status: InProgress (top-right)' }, + { key: 'status-failed', label: 'Status: Failed (top-right)' }, + { key: 'start-point', label: 'Start Point (bottom-left)' }, + { key: 'square-dashed', label: 'Square Dashed (bottom-right)' }, + { key: 'all', label: 'All Adornments' }, + { key: 'multi-exec', label: 'Multi-execution (count: 5)' }, +] as const; + +function createAdornmentGrid(): Node[] { + const nodes: Node[] = []; + + ADORNMENT_ROWS.forEach((row, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + nodes.push( + createNode({ + id: `adorn-${row.key}-${shape}`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { + label: shape, + subLabel: row.label, + shape, + }, + }, + }) + ); + }); + }); + + return nodes; +} + +function getAdornmentExecutionState(key: string) { + switch (key) { + case 'breakpoint': + return { status: 'None' as const, debug: true }; + case 'status-completed': + return { status: 'Completed' as const }; + case 'status-inprogress': + return { status: 'InProgress' as const }; + case 'status-failed': + return { status: 'Failed' as const }; + case 'start-point': + return { status: 'None' as const, isExecutionStartPoint: true }; + case 'square-dashed': + return { status: 'None' as const, isOutputPinned: true }; + case 'all': + return { + status: 'Completed' as const, + debug: true, + isExecutionStartPoint: true, + isOutputPinned: true, + }; + case 'multi-exec': + return { status: 'Completed' as const, count: 5 }; + default: + return undefined; + } +} + +function AdornmentsStory() { + const initialNodes = useMemo(() => createAdornmentGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +export const Adornments: Story = { + name: 'Adornments', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => { + const parts = nodeId.split('-'); + const key = parts.slice(1, -1).join('-'); + return getAdornmentExecutionState(key); + }, + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + +// ============================================================================ +// Stacked Treatment Story +// ============================================================================ + +const stackedManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + ...allCategoryManifests, + { + id: 'agents', + name: 'Agents', + sortOrder: 1, + color: '#7c3aed', + colorDark: '#8b5cf6', + icon: 'robot', + tags: [], + }, + ], + nodes: [ + ...allNodeManifests, + { + nodeType: 'uipath.agent.drillable', + version: '1.0.0', + category: 'agents', + tags: ['agent'], + sortOrder: 1, + drillable: true, + display: { + label: 'Drillable Agent', + icon: 'agent', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'input', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [{ id: 'output', type: 'source', handleType: 'output' }], + }, + ], + }, + ], +}; + +function StackedTreatmentStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'plain', + type: 'uipath.blank-node', + position: { x: 96, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Plain', subLabel: 'No treatment', shape: 'square' }, + }, + }), + createNode({ + id: 'drillable', + type: 'uipath.agent.drillable', + position: { x: 320, y: 200 }, + data: { + nodeType: 'uipath.agent.drillable', + version: '1.0.0', + display: { label: 'Drillable', subLabel: 'manifest.drillable', shape: 'square' }, + }, + }), + createNode({ + id: 'collapsed', + type: 'uipath.blank-node', + position: { x: 544, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Collapsed', subLabel: 'data.isCollapsed', shape: 'square' }, + isCollapsed: true, + }, + }), + ], + [] + ); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +export const StackedTreatment: Story = { + name: 'Stacked Treatment', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +// ============================================================================ +// canvasLabel Resolution Story +// ============================================================================ + +const canvasLabelManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + ...allCategoryManifests, + { + id: 'communications', + name: 'Communications', + sortOrder: 99, + color: '#3b82f6', + colorDark: '#60a5fa', + icon: 'agent', + tags: [], + }, + ], + nodes: [ + ...allNodeManifests, + { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + category: 'communications', + tags: ['email', 'outlook'], + sortOrder: 1, + display: { + label: 'Send Outlook 365 Email', + canvasLabel: 'Send Email', + icon: 'agent', + shape: 'rectangle', + }, + handleConfiguration: [ + { position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] }, + { position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] }, + ], + }, + { + nodeType: 'uipath.long-decision', + version: '1.0.0', + category: 'communications', + tags: ['decision'], + sortOrder: 2, + display: { + label: 'Long Decision Without canvasLabel', + icon: 'agent', + shape: 'rectangle', + }, + handleConfiguration: [ + { position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] }, + { position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] }, + ], + }, + ], +}; + +function CanvasLabelStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'with-canvaslabel', + type: 'uipath.send-outlook-email', + position: { x: 96, y: 160 }, + data: { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + display: { shape: 'rectangle', subLabel: 'manifest.canvasLabel wins' }, + }, + }), + createNode({ + id: 'without-canvaslabel', + type: 'uipath.long-decision', + position: { x: 96, y: 320 }, + data: { + nodeType: 'uipath.long-decision', + version: '1.0.0', + display: { + shape: 'rectangle', + subLabel: 'falls back to manifest.label since canvasLabel is not defined', + }, + }, + }), + createNode({ + id: 'instance-rename', + type: 'uipath.send-outlook-email', + position: { x: 96, y: 480 }, + data: { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + display: { + shape: 'rectangle', + canvasLabel: 'Notify Ops Team', + subLabel: 'instance.canvasLabel overrides manifest.canvasLabel', + }, + }, + }), + ], + [] + ); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + instance.label > manifest.canvasLabel > manifest.label.' + } + /> + + ); +} + +export const CanvasLabel: Story = { + name: 'canvasLabel resolution', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +export const ValidationStates: Story = { + name: 'Validation States', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: () => undefined, + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: (elementId: string) => { + const severity = elementId.split('-').pop() as string; + if (!['WARNING', 'ERROR', 'CRITICAL'].includes(severity)) return undefined; + return { + validationStatus: severity as ValidationErrorSeverity, + validationError: { + code: `VALIDATION_${severity}`, + message: validationMessages[severity] ?? `Validation ${severity.toLowerCase()}`, + description: validationMessages[severity] ?? `Validation ${severity.toLowerCase()}`, + severity: severity as ValidationErrorSeverity, + }, + }; + }, + }, + }), + ], + render: () => , +}; From f803c0d7d28e7b5e7e6dd6053b2dc32b36823319 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 14:51:23 -0700 Subject: [PATCH 03/16] feat(storybook): add LoopNode V2 anatomy, compound picker & execution count docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LoopNodeV2.stories.tsx with Anatomy page covering container structure, adornment slots, handles, and V2 Iterations section - Compound iteration picker: All toggle, per-iteration status dots, richer All aggregate (✓ completed / ✗ failed breakdown), jump-to-failed shortcut (⚠), and 1 px progress strip at the header bottom edge - Execution Count subsection documents how loop total (N) and child-node ↻ N badge relate — both surface the same loop execution count - Update BaseNodeV2 anatomy: Loop Count Badge reference table explaining slot, visibility condition, what N means, tooltip, and priority rule - Storybook sort order updated to surface Anatomy first in both V2 sidebars - Add AdornmentResolverProvider context to adornment-resolver.tsx so V2 stories can inject custom resolvers without affecting original stories Co-Authored-By: Claude Sonnet 4.6 --- apps/storybook/.storybook/preview.tsx | 2 +- .../canvas/components/BaseNode/BaseNode.tsx | 8 +- .../BaseNode/BaseNodeV2.stories.tsx | 567 ++++++- .../LoopNode/LoopNodeV2.stories.tsx | 1480 +++++++++++++++++ .../src/canvas/utils/adornment-resolver.tsx | 21 +- 5 files changed, 2068 insertions(+), 10 deletions(-) create mode 100644 packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index c0a885322..5fb944332 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -165,7 +165,7 @@ const preview: Preview = { '*', ], 'Canvas', - ['Components', ['All Components', 'BaseNode', 'BaseNode V2', '*'], '*'], + ['Components', ['All Components', 'BaseNode', 'BaseNode V2', ['Anatomy', '*'], 'LoopNode', 'LoopNode V2', ['Anatomy', '*'], '*'], '*'], ], }, }, diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 69e719fa0..9f32f24c4 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -24,7 +24,7 @@ import { useNodeTypeRegistry } from '../../core'; import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; import type { NodeShape } from '../../schema'; import type { HandleGroupManifest } from '../../schema/node-definition'; -import { resolveAdornments } from '../../utils/adornment-resolver'; +import { useAdornmentResolver } from '../../utils/adornment-resolver'; import { getIcon } from '../../utils/icon-registry'; import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; import { selectIsConnecting } from '../../utils/NodeUtils'; @@ -241,7 +241,9 @@ const BaseNodeComponent = (props: NodeProps>) => { return manifest ? resolveToolbar(manifest, statusContext) : undefined; }, [toolbarConfigProp, manifest, statusContext]); - // Adornments resolution: use default resolver, then override with props if provided + // Adornments resolution: use default resolver, then override with props if provided. + // The resolver can be swapped per-story via AdornmentResolverProvider (V2 prototype use). + const resolveAdornments = useAdornmentResolver(); const adornments: NodeAdornments = useMemo(() => { const adornmentsFromProps = adornmentsProp ?? {}; const adornmentsFromResolver = resolveAdornments(statusContext); @@ -250,7 +252,7 @@ const BaseNodeComponent = (props: NodeProps>) => { ...adornmentsFromResolver, ...adornmentsFromProps, }; - }, [adornmentsProp, statusContext]); + }, [adornmentsProp, resolveAdornments, statusContext]); // Compute height: max of base height (user-specified or measured) and handle minimum. // baseHeightRef is updated above from external height changes; handle inflation diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx index 474593cba..72328cdfc 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx @@ -15,11 +15,23 @@ import { withCanvasProviders, } from '../../storybook-utils'; import { DefaultCanvasTranslations } from '../../types'; -import type { ValidationErrorSeverity } from '../../types/validation'; +import { ValidationErrorSeverity } from '../../types/validation'; +import { + AdornmentResolverProvider, + BreakpointIndicator, + ExecutionStartPointIndicator, + ExecutionStatusIndicator, + SquareDashedIndicator, + ValidationErrorIndicator, + ValidationWarningIndicator, +} from '../../utils/adornment-resolver'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { CanvasTooltip } from '../CanvasTooltip'; +import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; +import type { BaseNodeData, NodeAdornments, NodeStatusContext } from './BaseNode.types'; import { BaseCanvas } from '../BaseCanvas'; import { CanvasPositionControls } from '../CanvasPositionControls'; import { NodeInspector } from '../NodeInspector'; -import type { BaseNodeData } from './BaseNode.types'; // ============================================================================ // Meta Configuration @@ -565,10 +577,293 @@ function DynamicHandlesStory() { ); } +// ============================================================================ +// Anatomy Story — full-page documentation layout +// ============================================================================ + +const SHAPE_DOCS = [ + { + label: 'Circle', + icon: 'repeat', + usage: 'Trigger / start nodes', + code: 'shape: "circle"', + borderRadius: '50%', + size: { width: 64, height: 64 }, + }, + { + label: 'Square', + icon: 'agent', + usage: 'Standard action nodes', + code: 'shape: "square"', + borderRadius: 16, + size: { width: 64, height: 64 }, + }, + { + label: 'Rectangle', + icon: 'agent', + usage: 'Agent / wide nodes', + code: 'shape: "rectangle"', + borderRadius: 16, + size: { width: 96, height: 64 }, + }, +] as const; + +const SLOT_DOCS = [ + { slot: 'topLeft', dot: 'bg-red-500', rule: 'Breakpoint', detail: 'Debug mode — pauses execution at this node.' }, + { slot: 'topRight', dot: 'bg-emerald-500', rule: 'Status › Validation error › Warning', detail: 'First matching state wins. Clears when execution ends.' }, + { slot: 'bottomLeft', dot: 'bg-blue-500', rule: 'Execution start point', detail: 'Marks the entry node for the current run.' }, + { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Loop count (> 1) › Output pinned', detail: 'Loop count takes priority when both are active.' }, +] as const; + +function AnatomyStory() { + return ( +
+
+ + {/* ── Page header ── */} +
+

BaseNode Anatomy

+

+ BaseNode renders in three shapes. Four 20×20 px adornment slots sit at each corner of the + node — each with a defined priority chain that determines what content to show. +

+
+ +
+ + {/* ── Shapes ── */} +
+
+

Shapes

+

+ Controlled by display.shape on + the node data or manifest. All shapes share the same slot anatomy. +

+
+
+ {SHAPE_DOCS.map(({ label, icon, usage, code, borderRadius, size }) => ( +
+
+ +
+
+
{label}
+
{usage}
+
+ + {code} + +
+ ))} +
+
+ +
+ + {/* ── Adornment Slots ── */} +
+
+

Adornment Slots

+

+ Four 20×20 px slots, inset 6 px from each corner. Each slot runs its priority chain and + renders the first matching condition. +

+
+ + {/* Diagram */} +
+
+ {/* Left labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ + {/* Node mock at 96×96 — actual CSS from BaseNode */} +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* Right labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))} +
+
+
+ + {/* Reference table */} +
+ + + + + + + + + + {SLOT_DOCS.map(({ slot, dot, rule, detail }, i) => ( + + + + + + ))} + +
SlotPriority chainNotes
+
+
+ {slot} +
+
{rule}{detail}
+
+
+ +
+ + {/* ── V2 Iterations ── */} +
+
+

V2 Iterations

+

+ Improvements introduced in the V2 prototype. All changes are scoped to BaseNode V2 + only — the original BaseNode story is unaffected. +

+
+ + {/* Subsection: Loop Count Pill */} +
+

Loop Count Badge

+

+ The ↻ N badge + shows how many times this node has executed. For a node inside a loop, N equals the + number of loop iterations completed — it increments by 1 after each iteration. + The badge only appears when N > 1. +

+ + {/* V1 vs V2 comparison */} +
+
+
+
V1 — count inline
+
+ 3 + +
+

Count prefixes the status icon. Both pieces of information share one slot.

+
+ +
+ +
+
V2 — dedicated slots
+
+
+ + topRight +
+
+ + bottomRight +
+
+

Status and count each occupy their own corner slot — both visible simultaneously.

+
+
+
+ + {/* Reference table */} +
+ + + + + + + + + {([ + { prop: 'Badge', value: '↻ repeat icon + count number' }, + { prop: 'Slot', value: 'bottomRight adornment' }, + { prop: 'Visible when', value: 'count > 1' }, + { prop: 'What N means', value: 'Times this node has run — one per completed loop iteration' }, + { prop: 'Tooltip', value: '"Executed N times"' }, + { prop: 'Priority', value: 'Overrides the output-pinned badge when both are active' }, + ] as const).map(({ prop, value }, i, arr) => ( + + + + + ))} + +
PropertyValue
{prop}{value}
+
+
+
+ +
+
+ ); +} + // ============================================================================ // Exported Stories // ============================================================================ +export const Anatomy: Story = { + name: 'Anatomy', + render: () => , +}; + export const Default: Story = { name: 'Default', render: () => , @@ -650,6 +945,219 @@ function ValidationStatesStory() { ); } +// ============================================================================ +// Option B: Loop Count Pill (V2 prototype — does not affect original BaseNode) +// ============================================================================ + +function LoopCountPill({ count }: { count: number }) { + return ( + +
+ + + {count} + +
+
+ ); +} + +// Custom resolver for V2: moves the loop count out of the status icon (top-right) +// into a dedicated pill slot (bottom-right), leaving the status icon uncluttered. +function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments { + const executionState = context.executionState; + + const status = typeof executionState === 'object' ? executionState?.status : executionState; + const count = typeof executionState === 'object' ? executionState.count : undefined; + const hasBreakpoint = typeof executionState === 'object' && executionState?.debug; + const isExecutionStartPoint = + typeof executionState === 'object' && executionState?.isExecutionStartPoint; + const isOutputPinned = typeof executionState === 'object' && executionState?.isOutputPinned; + + const hasValidationError = + context.validationState?.validationStatus === ValidationErrorSeverity.ERROR || + context.validationState?.validationStatus === ValidationErrorSeverity.CRITICAL; + const hasValidationWarning = + context.validationState?.validationStatus === ValidationErrorSeverity.WARNING; + + const getTopRight = () => { + if (status && status !== 'None') return ; + if (hasValidationError) + return ( + + ); + if (hasValidationWarning) + return ( + + ); + return undefined; + }; + + const hasLoopCount = count !== undefined && count > 1; + + return { + topLeft: hasBreakpoint ? : undefined, + topRight: getTopRight(), + bottomLeft: isExecutionStartPoint ? : undefined, + // Loop count takes priority over output-pinned when both are present + bottomRight: hasLoopCount ? ( + + ) : isOutputPinned ? ( + + ) : undefined, + }; +} + +// ============================================================================ +// Node Anatomy Diagram +// ============================================================================ + +const SLOT_LEGEND = [ + { + label: 'topLeft', + dot: 'bg-red-500', + rule: 'Breakpoint', + detail: 'Debug mode only — pauses execution at this node.', + side: 'left', + }, + { + label: 'topRight', + dot: 'bg-emerald-500', + rule: 'Status › Validation error › Warning', + detail: 'First matching state wins. Clears when execution ends.', + side: 'right', + }, + { + label: 'bottomLeft', + dot: 'bg-blue-500', + rule: 'Execution start point', + detail: 'Marks the entry node for the current run.', + side: 'left', + }, + { + label: 'bottomRight', + dot: 'bg-amber-500', + rule: 'Loop count (> 1) › Output pinned', + detail: 'Loop count takes priority when both are active.', + side: 'right', + }, +] as const; + +function NodeAnatomyDiagram() { + const leftSlots = SLOT_LEGEND.filter((s) => s.side === 'left'); + const rightSlots = SLOT_LEGEND.filter((s) => s.side === 'right'); + + return ( + + {/* Diagram: labels flanking the real node mock */} +
+ {/* Left labels — aligned to top and bottom of node */} +
+ {leftSlots.map(({ label, dot, rule }) => ( +
+ + {label} + {rule} + +
+
+ ))} +
+ + {/* The node — uses exact BaseNode styles at 96 × 96 px */} +
+ {/* Outer container */} +
+ {/* Inner shape */} +
+ +
+
+ + {/* topLeft — Breakpoint */} +
+
+
+ + {/* topRight — Execution status (Completed) */} +
+ +
+ + {/* bottomLeft — Start point */} +
+
+
+ + {/* bottomRight — Loop count pill */} +
+ +
+
+ + {/* Right labels */} +
+ {rightSlots.map(({ label, dot, rule }) => ( +
+
+ + {label} + {rule} + +
+ ))} +
+
+ +

+ Each slot is 20 × 20 px, inset 6 px from the node corner. +

+ +
+ + {/* Full slot details */} + + {SLOT_LEGEND.map(({ label, dot, rule, detail }) => ( +
+
+ +
+ {label} + {rule} +
+ {detail} +
+
+ ))} + + + ); +} + // ============================================================================ // Adornments Story // ============================================================================ @@ -723,6 +1231,42 @@ function getAdornmentExecutionState(key: string) { } } +const ADORNMENT_DESCRIPTIONS: { label: string; description: string }[] = [ + { + label: 'Breakpoint', + description: 'Pauses execution at this node during a debug run.', + }, + { + label: 'Status: Completed', + description: 'Node finished executing successfully.', + }, + { + label: 'Status: In Progress', + description: 'Node is actively running.', + }, + { + label: 'Status: Failed', + description: 'Node encountered an error during execution.', + }, + { + label: 'Start Point', + description: 'Entry point for the current execution run.', + }, + { + label: 'Square Dashed', + description: 'Output is pinned — result saved for later reference.', + }, + { + label: 'All Adornments', + description: 'All four corners populated at the same time.', + }, + { + label: 'Multi-execution', + description: + 'Loop pill in the bottom-right corner: repeat icon + count. Separate from the execution status icon (top-right) so both are visible at once.', + }, +]; + function AdornmentsStory() { const initialNodes = useMemo(() => createAdornmentGrid(), []); const { canvasProps } = useCanvasStory({ initialNodes }); @@ -734,8 +1278,18 @@ function AdornmentsStory() { + description="Each row demonstrates one adornment slot. See the Anatomy story for slot positions and priority rules." + collapsible + > + + {ADORNMENT_DESCRIPTIONS.map(({ label, description }) => ( + + {label} + {description} + + ))} + + ); } @@ -743,6 +1297,11 @@ function AdornmentsStory() { export const Adornments: Story = { name: 'Adornments', decorators: [ + (Story) => ( + + + + ), withCanvasProviders({ executionState: { getNodeExecutionState: (nodeId: string) => { diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx new file mode 100644 index 000000000..c94d92570 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -0,0 +1,1480 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + type Edge, + type Node, + type NodeProps, + Panel, + Position, + useReactFlow, +} from '@uipath/apollo-react/canvas/xyflow/react'; +import { cn } from '@uipath/apollo-wind'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAddNodeOnConnectEnd, useCanvasEvent } from '../../hooks'; +import { + createNode, + StoryInfoPanel, + useCanvasStory, + withCanvasProviders, +} from '../../storybook-utils'; +import { DefaultCanvasTranslations } from '../../types'; +import { ElementStatusValues } from '../../types/execution'; +import type { CanvasHandleActionEvent } from '../../utils'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { removePreviewFromReactFlow } from '../../utils/createPreviewNode'; +import { snapToGrid } from '../../utils/NodeUtils'; +import { AddNodeManager } from '../AddNodePanel'; +import { createAddNodePreview } from '../AddNodePanel/createAddNodePreview'; +import { BaseCanvas } from '../BaseCanvas'; +import type { BaseNodeData } from '../BaseNode/BaseNode.types'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import { LoopNode } from './LoopNode'; +import type { LoopNodeData } from './LoopNode.types'; + +const meta: Meta = { + title: 'Components/LoopNode V2', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const LOOP_TYPE = 'uipath.control-flow.foreach'; +const ACTIVITY_TYPE = 'uipath.blank-node'; +const STORY_LOOP_START_HANDLE_ID = 'start'; +const STORY_LOOP_CONTINUE_HANDLE_ID = 'continue'; +const STORY_LOOP_SUCCESS_HANDLE_ID = 'success'; + +const snapPoint = (point: { x: number; y: number }) => ({ + x: snapToGrid(point.x), + y: snapToGrid(point.y), +}); + +const snapSize = (size: { width: number; height: number }) => ({ + width: snapToGrid(size.width), + height: snapToGrid(size.height), +}); + +function createLoopContainerNode( + id: string, + position: { x: number; y: number }, + size: { width: number; height: number }, + options?: { parentId?: string; selected?: boolean; data?: LoopNodeData } +): Node { + const snappedSize = snapSize(size); + const display = { + ...options?.data?.display, + shape: 'container' as const, + }; + + return { + id, + type: LOOP_TYPE, + position: snapPoint(position), + parentId: options?.parentId, + selected: options?.selected ?? false, + data: { + ...options?.data, + display, + }, + style: { width: snappedSize.width, height: snappedSize.height }, + }; +} + +function createActivityNode( + id: string, + label: string, + position: { x: number; y: number }, + options?: { parentId?: string; subLabel?: string | null } +): Node { + const node = createNode({ + id, + type: ACTIVITY_TYPE, + position: snapPoint(position), + display: options?.subLabel ? { label, subLabel: options.subLabel } : { label }, + }); + + if (options?.parentId) { + return { + ...node, + parentId: options.parentId, + }; + } + + return node; +} + +interface AutoPreviewSource { + nodeId: string; + handleId: string; + position?: Position; +} + +interface StoryInfo { + title: string; + description: string; +} + +interface LoopCanvasStoryProps { + initialNodes: Node[]; + initialEdges: Edge[]; + autoPreviewSource?: AutoPreviewSource; + storyInfo: StoryInfo; +} + +function LoopCanvasStory({ + initialNodes, + initialEdges, + autoPreviewSource, + storyInfo, +}: LoopCanvasStoryProps) { + const reactFlow = useReactFlow(); + const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd(); + const autoPreviewedRef = useRef(false); + + const { canvasProps, nodeTypeRegistry } = useCanvasStory({ + initialNodes, + initialEdges, + }); + + const loopPreviewOptions = useMemo( + () => ({ + getManifestForNode: (node: Node) => + node.type ? nodeTypeRegistry.getManifest(node.type) : undefined, + }), + [nodeTypeRegistry] + ); + + useEffect(() => { + if (!autoPreviewSource || autoPreviewedRef.current) return; + + const frame = window.requestAnimationFrame(() => { + if (autoPreviewedRef.current) return; + + autoPreviewedRef.current = true; + createAddNodePreview( + autoPreviewSource.nodeId, + autoPreviewSource.handleId, + reactFlow, + autoPreviewSource.position ?? Position.Right, + 'source', + [], + loopPreviewOptions + ); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [autoPreviewSource, loopPreviewOptions, reactFlow]); + + const handleHandleAction = useCallback( + (event: CanvasHandleActionEvent) => { + const { handleId, nodeId, position, handleType } = event; + if (!handleId || !nodeId) return; + + createAddNodePreview( + nodeId, + handleId, + reactFlow, + position as Position, + handleType === 'input' ? 'target' : 'source', + [], + loopPreviewOptions + ); + }, + [loopPreviewOptions, reactFlow] + ); + + useCanvasEvent('handle:action', handleHandleAction); + + const handlePaneClick = useCallback(() => { + removePreviewFromReactFlow(reactFlow); + }, [reactFlow]); + + return ( + + + + + + + + ); +} + +function DefaultStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 256 }), + createLoopContainerNode( + 'loop-1', + { x: 224, y: 128 }, + { width: 704, height: 368 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createActivityNode('child-1', 'Analyze claims', { x: 160, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('child-2', 'Write outcome', { x: 432, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('egress', 'Publish results', { x: 1024, y: 256 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: 'input', + }, + { + id: 'loop-child-1', + source: 'loop-1', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'child-1', + targetHandle: 'input', + }, + { + id: 'child-1-child-2', + source: 'child-1', + sourceHandle: 'output', + target: 'child-2', + targetHandle: 'input', + }, + { + id: 'child-2-loop', + source: 'child-2', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'loop-egress', + source: 'loop-1', + sourceHandle: 'success', + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +function NestedOuterOutputInsertStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 288 }), + createLoopContainerNode( + 'outer-loop', + { x: 192, y: 80 }, + { width: 1040, height: 496 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createLoopContainerNode( + 'inner-loop', + { x: 128, y: 112 }, + { width: 496, height: 304 }, + { + parentId: 'outer-loop', + data: { display: { label: 'For Each attachment' } }, + } + ), + createActivityNode( + 'inner-child', + 'Classify attachment', + { x: 176, y: 112 }, + { parentId: 'inner-loop' } + ), + createActivityNode( + 'review', + 'Review finding', + { x: 720, y: 216 }, + { parentId: 'outer-loop' } + ), + createActivityNode('egress', 'Publish results', { x: 1296, y: 288 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-outer-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: 'input', + }, + { + id: 'outer-loop-inner-loop', + source: 'outer-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-loop', + targetHandle: 'input', + }, + { + id: 'inner-loop-inner-child', + source: 'inner-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-child', + targetHandle: 'input', + }, + { + id: 'inner-child-inner-loop', + source: 'inner-child', + sourceHandle: 'output', + target: 'inner-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'inner-loop-review', + source: 'inner-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'review', + targetHandle: 'input', + }, + { + id: 'review-outer-loop', + source: 'review', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'outer-loop-egress', + source: 'outer-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +function NestedOuterOutputAppendStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 272 }), + createLoopContainerNode( + 'outer-loop', + { x: 224, y: 96 }, + { width: 896, height: 448 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createLoopContainerNode( + 'inner-loop', + { x: 160, y: 112 }, + { width: 544, height: 304 }, + { + parentId: 'outer-loop', + data: { display: { label: 'For Each attachment' } }, + } + ), + createActivityNode( + 'inner-child', + 'Classify attachment', + { x: 176, y: 112 }, + { parentId: 'inner-loop' } + ), + createActivityNode('egress', 'Publish results', { x: 1216, y: 272 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-outer-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: 'input', + }, + { + id: 'outer-loop-inner-loop', + source: 'outer-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-loop', + targetHandle: 'input', + }, + { + id: 'inner-loop-inner-child', + source: 'inner-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-child', + targetHandle: 'input', + }, + { + id: 'inner-child-inner-loop', + source: 'inner-child', + sourceHandle: 'output', + target: 'inner-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'outer-loop-egress', + source: 'outer-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +type LoopExecutionNodeData = LoopNodeData & { + initialIndex: number; + total: number; + interactive?: boolean; +}; + +const LOOP_EXECUTION_SIZE = { width: 520, height: 280 }; +const LOOP_EXECUTION_GRID = { + startX: 80, + startY: 80, + gapX: 640, + gapY: 360, +} as const; + +const LOOP_EXECUTION_CASES: { + id: string; + label: string; + status: ElementStatusValues; + initialIndex: number; + total: number; + parallel?: boolean; + interactive?: boolean; +}[] = [ + { + id: 'loop-completed', + label: 'Completed loop', + status: ElementStatusValues.Completed, + initialIndex: 2, + total: 3, + }, + { + id: 'loop-running', + label: 'Running loop', + status: ElementStatusValues.InProgress, + initialIndex: 1, + total: 3, + }, + { + id: 'loop-paused', + label: 'Paused loop', + status: ElementStatusValues.Paused, + initialIndex: 1, + total: 4, + }, + { + id: 'loop-failed', + label: 'Failed loop', + status: ElementStatusValues.Failed, + initialIndex: 0, + total: 3, + }, + { + id: 'loop-cancelled', + label: 'Cancelled', + status: ElementStatusValues.Cancelled, + initialIndex: 2, + total: 5, + }, + { + id: 'loop-parallel', + label: 'Parallel loop', + status: ElementStatusValues.Completed, + initialIndex: 2, + total: 3, + parallel: true, + }, + { + id: 'loop-label-only', + label: 'Label only', + status: ElementStatusValues.Completed, + initialIndex: 1, + total: 3, + interactive: false, + }, + { + id: 'loop-clamped', + label: 'Clamped active index', + status: ElementStatusValues.Completed, + initialIndex: 99, + total: 3, + }, +]; + +const LOOP_EXECUTION_STATUS = new Map(LOOP_EXECUTION_CASES.map(({ id, status }) => [id, status])); + +function createExecutionStateGrid(): Node[] { + return LOOP_EXECUTION_CASES.map( + ({ id, label, initialIndex, total, parallel, interactive }, index) => { + const colIndex = index % 2; + const rowIndex = Math.floor(index / 2); + + return { + id, + type: LOOP_TYPE, + position: { + x: LOOP_EXECUTION_GRID.startX + colIndex * LOOP_EXECUTION_GRID.gapX, + y: LOOP_EXECUTION_GRID.startY + rowIndex * LOOP_EXECUTION_GRID.gapY, + }, + data: { + display: { label, shape: 'container' }, + parallel, + initialIndex, + total, + interactive, + }, + style: LOOP_EXECUTION_SIZE, + }; + } + ); +} + +function LoopExecutionCanvasNode(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState(data.initialIndex); + + useEffect(() => { + setActiveIndex(data.initialIndex); + }, [data.initialIndex]); + + return ( + + ); +} + +const LOOP_EXECUTION_NODE_TYPES = { + [LOOP_TYPE]: LoopExecutionCanvasNode, +}; + +function ExecutionStatesStory() { + const initialNodes = useMemo(() => createExecutionStateGrid(), []); + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES, + }); + + return ( + + + + + + + ); +} + +// ============================================================================ +// Anatomy: LoopNode — full-page documentation layout +// ============================================================================ + +const LOOP_SLOT_DOCS = [ + { slot: 'topLeft', dot: 'bg-red-500', rule: 'Breakpoint', detail: 'Debug mode — pauses execution at this node.' }, + { slot: 'topRight', dot: 'bg-emerald-500', rule: 'Status › Validation error › Warning', detail: 'First matching state wins.' }, + { slot: 'bottomLeft', dot: 'bg-blue-500', rule: 'Execution start point', detail: 'Marks the entry node for the current run.' }, + { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Loop count (> 1) › Output pinned', detail: 'Loop count takes priority when both are active.' }, +] as const; + +const LOOP_HANDLE_DOCS = [ + { handle: 'input', side: 'Left outer', boundary: 'outer', description: 'Incoming connection — the edge entering the loop.' }, + { handle: 'success', side: 'Right outer', boundary: 'outer', description: 'Loop completed — exits when all iterations finish.' }, + { handle: 'start', side: 'Inner top', boundary: 'inner', description: 'First activity inside the loop body connects here.' }, + { handle: 'continue', side: 'Inner bottom', boundary: 'inner', description: 'Last activity loops back here to begin the next iteration.' }, +] as const; + +const PICKER_DOCS = [ + { ctrl: '●', label: 'Status dot', desc: 'Colored dot indicating the current iteration\'s outcome (green = Completed, red = Failed, amber = In Progress, purple = Paused, grey = Cancelled). Only shown when per-iteration status data is available.' }, + { ctrl: '|◄', label: 'Jump first', desc: 'Jumps to iteration 1.' }, + { ctrl: '◄', label: 'Step back', desc: 'Moves one iteration back. Disabled at the start.' }, + { ctrl: '[index / total]', label: 'Position', desc: 'Click the index number to activate an inline input — type a target, Enter to commit, Escape to cancel.' }, + { ctrl: '►', label: 'Step forward', desc: 'Moves one iteration forward. Disabled at the end.' }, + { ctrl: '►|', label: 'Jump last', desc: 'Jumps to the final iteration.' }, + { ctrl: 'All', label: 'All toggle', desc: 'Collapses into an aggregate view. With status data shows ✓ completed ✗ failed; otherwise Σ total. Click again to return to individual view.' }, + { ctrl: '⚠', label: 'Jump to failed', desc: 'Visible only when at least one iteration has a Failed status. Jumps directly to the first failed iteration.' }, +] as const; + +// Demo data for anatomy live demo +const DEMO_ITERATION_STATUSES = new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'Failed'], + [3, 'Completed'], + [4, 'InProgress'], +]); +const DEMO_TOTAL = 8; + +function AnatomyStory() { + const [demoIndex, setDemoIndex] = useState(0); + const [demoIsAll, setDemoIsAll] = useState(false); + + return ( +
+
+ + {/* ── Page header ── */} +
+

LoopNode Anatomy

+

+ LoopNode is a resizable container with a header bar, a dashed body frame for child nodes, + and four corner adornment slots. It exposes four handles — two outer edge handles for the + process flow and two inner handles for the loop body. +

+
+ +
+ + {/* ── Container Structure ── */} +
+
+

Container Structure

+

+ Three layers compose the loop container. The header's negative bottom margin creates a + flush visual join between the header and the body frame. +

+
+ + {/* Loop node mock */} +
+
+
+
+
+ + For Each claim +
+ + + Sequential + +
+
+ child nodes +
+
+
+
+ +
+ {[ + { label: 'Outer container', token: 'rounded-[20px] border', desc: 'Receives hover, selected, and drag states via outline and shadow overrides.' }, + { label: 'Header', token: 'bg-surface-overlay -mb-2.5', desc: 'Negative bottom margin creates the overlap with the body frame.' }, + { label: 'Body frame', token: 'border-dashed rounded-xl m-2.5', desc: 'Child nodes live inside here. Drives minimum container resize calculations.' }, + ].map(({ label, token, desc }) => ( +
+
{label}
+ {token} +
{desc}
+
+ ))} +
+
+ +
+ + {/* ── Adornment Slots ── */} +
+
+

Adornment Slots

+

+ LoopNode uses the same four 20×20 px corner slots as BaseNode, positioned on the outer + container. Priority rules are identical — see BaseNode V2 → Anatomy for the diagram. +

+
+
+ + + + + + + + + + {LOOP_SLOT_DOCS.map(({ slot, dot, rule, detail }, i) => ( + + + + + + ))} + +
SlotPriority chainNotes
+
+
+ {slot} +
+
{rule}{detail}
+
+
+ +
+ + {/* ── Handles ── */} +
+
+

Handles

+

+ Two outer handles carry the main process flow; two inner handles define the loop body + entry and continuation points. +

+
+
+ + + + + + + + + + + {LOOP_HANDLE_DOCS.map(({ handle, side, boundary, description }, i) => ( + + + + + + + ))} + +
Handle IDPositionBoundaryDescription
+ {handle} + {side} + + {boundary} + + {description}
+
+
+ +
+ + {/* ── V2 Iterations ── */} +
+
+

V2 Iterations

+

+ Improvements introduced in the V2 prototype. All changes are scoped to LoopNode V2 + only — the original LoopNode story is unaffected. +

+
+ + {/* Subsection: Execution Count */} +
+

Execution Count

+

+ How many times the loop body has run. This count surfaces in two places at once — + as the denominator in the iteration picker on the loop itself, and as a{' '} + ↻ N{' '} + badge on each child node inside the loop. +

+ +
+
+ +
+
Loop — iteration picker
+
+ 2 / + 5 +
+

+ The denominator 5 is the loop's + execution count — how many iterations have been run or are planned for this run. +

+
+ +
+
Child node — loop count badge
+
+ + 5 +
+

+ Each child node shows the same count in its{' '} + bottomRight{' '} + adornment slot. When the loop finishes, both numbers match. +

+
+ +
+
+ +
+ + + + + + + + + + {([ + { prop: 'total (N)', loc: 'Picker denominator', meaning: 'Total iterations the loop will run. Provided by the execution runtime.' }, + { prop: 'activeIndex (k)', loc: 'Picker numerator', meaning: 'The iteration currently being viewed, 1-based (displayed as k = activeIndex + 1).' }, + { prop: 'count', loc: 'Child node ↻ N badge', meaning: 'How many times this child node has executed so far. Equals total when the loop completes.' }, + ] as const).map(({ prop, loc, meaning }, i, arr) => ( + + + + + + ))} + +
PropertyLocationMeaning
{prop}{loc}{meaning}
+
+
+ + {/* Subsection: Compound Iteration Picker */} +
+

Compound Iteration Picker

+

+ Replaces the original single ◄ / ► controls with a compound picker and an "All" + aggregate toggle. Try it below. +

+ +
+
Live demo
+
+ { setDemoIsAll(false); setDemoIndex(i); }, + isAll: demoIsAll, + onAllChange: setDemoIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ +
+ + + + + + + + + + {PICKER_DOCS.map(({ ctrl, label, desc }, i) => ( + + + + + + ))} + +
ControlLabelBehaviour
+ {ctrl} + {label}{desc}
+
+
+ + {/* Subsection: Iteration Progress Strip */} +
+

Iteration Progress Strip

+

+ A 3 px bar at the bottom edge of the loop header shows how many iterations have + reached a terminal state (Completed, Failed, or Cancelled) as a fraction of the + total. The fill turns red when any iteration has failed; green otherwise. +

+ +
+
Strip states
+
+ {([ + { label: '0 / 8 — not started', pct: 0, hasFailed: false }, + { label: '3 / 8 — in progress', pct: 3 / 8, hasFailed: false }, + { label: '5 / 8 — partial run with failure', pct: 5 / 8, hasFailed: true }, + { label: '8 / 8 — all iterations complete', pct: 1, hasFailed: false }, + ] as const).map(({ label, pct, hasFailed }) => ( +
+
{label}
+
+
0 ? '#22c55e' : 'transparent', + borderRadius: 3, + }} /> +
+
+ ))} +
+
+
+ +
+ +
+
+ ); +} + +// ============================================================================ +// V2: Compound Iteration Picker + "All" aggregate toggle +// Lives entirely in this story file — no shared component changes. +// LoopNode is rendered with iterationState={undefined} (native nav hidden) +// and the V2 nav is overlaid absolutely into the header area. +// ============================================================================ + +interface LoopIterationStateV2 { + activeIndex: number; + total: number; + onActiveIndexChange?: (nextIndex: number) => void; + disabled?: boolean; + isAll: boolean; + onAllChange: (isAll: boolean) => void; + iterationStatuses?: Map; +} + +function stopV2Event(e: React.SyntheticEvent) { + e.stopPropagation(); +} + +function V2NavButton({ + onClick, + disabled, + ariaLabel, + children, +}: { + onClick: (e: React.MouseEvent) => void; + disabled: boolean; + ariaLabel: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function getIterationStatusColor(status: string | undefined): string { + switch (status) { + case 'Completed': return '#22c55e'; + case 'Failed': return '#ef4444'; + case 'InProgress': return '#f59e0b'; + case 'Paused': return '#a855f7'; + case 'Cancelled': return '#94a3b8'; + default: return 'currentColor'; + } +} + +function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { + const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses } = state; + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const canInteract = !disabled && typeof onActiveIndexChange === 'function'; + const visibleIndex = activeIndex + 1; + + const clampToRange = (v: number) => Math.max(1, Math.min(total, v)); + + // Derived status info from per-iteration status map + const currentStatus = iterationStatuses?.get(activeIndex); + const firstFailedIndex = iterationStatuses + ? [...iterationStatuses.entries()].find(([, s]) => s === 'Failed')?.[0] + : undefined; + const completedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Completed').length + : undefined; + const failedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Failed').length + : 0; + + const handleFirst = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll) onActiveIndexChange?.(0); + }; + + const handlePrev = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex > 0) onActiveIndexChange?.(activeIndex - 1); + }; + + const handleNext = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex < total - 1) onActiveIndexChange?.(activeIndex + 1); + }; + + const handleLast = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll) onActiveIndexChange?.(total - 1); + }; + + const startEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!canInteract || isAll || isEditing) return; + setInputValue(String(visibleIndex)); + setIsEditing(true); + requestAnimationFrame(() => { + inputRef.current?.select(); + }); + }; + + const commitEdit = () => { + const parsed = parseInt(inputValue, 10); + if (!Number.isNaN(parsed)) { + onActiveIndexChange?.(clampToRange(parsed) - 1); + } + setIsEditing(false); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') setIsEditing(false); + }; + + const toggleAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onAllChange(!isAll); + }; + + const handleJumpToFailed = (e: React.MouseEvent) => { + e.stopPropagation(); + if (firstFailedIndex !== undefined) onActiveIndexChange?.(firstFailedIndex); + }; + + const canGoFirst = canInteract && !isAll && activeIndex > 0; + const canGoPrev = canInteract && !isAll && activeIndex > 0; + const canGoNext = canInteract && !isAll && activeIndex < total - 1; + const canGoLast = canInteract && !isAll && activeIndex < total - 1; + + return ( +
+ {/* All toggle chip */} + + + {isAll ? ( + /* Aggregate badge — richer ✓/✗ breakdown when status data available */ + + {completedCount !== undefined ? ( + <> + ✓ {completedCount} + {failedCount > 0 && ( + ✗ {failedCount} + )} + + ) : ( + <> + Σ + {total} + + )} + + ) : ( + /* Compound picker: |◄ ◄ [index/total] ► ►| */ +
+ + Iteration {visibleIndex} of {total} + + + + + + + + + + {/* Editable fraction with status dot — click index number to jump to a specific iteration */} + + {isEditing ? ( + <> + setInputValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleInputKeyDown} + onPointerDown={stopV2Event} + className="w-7 appearance-none bg-transparent text-center text-[11px] font-semibold leading-4 outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none border-b border-foreground-accent" + /> + / + {total} + + ) : ( + <> + {currentStatus && ( + + )} + {visibleIndex} + / + {total} + + )} + + + + + + + + +
+ )} + + {/* Jump-to-failed — only visible when a failed iteration exists */} + {firstFailedIndex !== undefined && !isAll && canInteract && ( + + )} +
+ ); +} + +type LoopExecutionNodeDataV2 = LoopNodeData & { + initialIndex: number; + total: number; + interactive?: boolean; + iterationStatuses?: Map; +}; + +// Realistic per-iteration status data for each V2 execution demo node +const LOOP_EXECUTION_ITERATION_STATUSES = new Map>([ + ['loop-completed', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-running', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['loop-paused', new Map([[0, 'Completed'], [1, 'Paused']])], + ['loop-failed', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Failed']])], + ['loop-cancelled', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Cancelled']])], + ['loop-parallel', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-label-only', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-clamped', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], +]); + +function createExecutionStateGridV2(): Node[] { + return LOOP_EXECUTION_CASES.map(({ id, label, initialIndex, total, parallel, interactive }, index) => { + const colIndex = index % 2; + const rowIndex = Math.floor(index / 2); + return { + id, + type: LOOP_TYPE, + position: { + x: LOOP_EXECUTION_GRID.startX + colIndex * LOOP_EXECUTION_GRID.gapX, + y: LOOP_EXECUTION_GRID.startY + rowIndex * LOOP_EXECUTION_GRID.gapY, + }, + data: { + display: { label, shape: 'container' }, + parallel, + initialIndex, + total, + interactive, + iterationStatuses: LOOP_EXECUTION_ITERATION_STATUSES.get(id), + }, + style: LOOP_EXECUTION_SIZE, + }; + }); +} + +// V2 HEADER_OVERLAY_TOP: aligns with header's pt-2.5 (10px) to vertically center in the h-6 content row +// V2 HEADER_OVERLAY_RIGHT: clears the Sequential/Parallel badge (~105px) + gap-2 (8px) + px-3.5 (14px) +const V2_OVERLAY_TOP = 9; +const V2_OVERLAY_RIGHT = 130; + +function LoopExecutionCanvasNodeV2(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState( + Math.max(0, Math.min(data.total - 1, data.initialIndex)) + ); + const [isAll, setIsAll] = useState(false); + + useEffect(() => { + setActiveIndex(Math.max(0, Math.min(data.total - 1, data.initialIndex))); + setIsAll(false); + }, [data.initialIndex, data.total]); + + const progressInfo = useMemo(() => { + if (!data.iterationStatuses || data.iterationStatuses.size === 0) return null; + const vals = [...data.iterationStatuses.values()]; + const completed = vals.filter(s => s === 'Completed').length; + const failed = vals.filter(s => s === 'Failed').length; + const terminated = completed + failed + vals.filter(s => s === 'Cancelled').length; + return { pct: terminated / data.total, hasFailed: failed > 0 }; + }, [data.iterationStatuses, data.total]); + + const iterationState: LoopIterationStateV2 = { + activeIndex, + total: data.total, + onActiveIndexChange: + data.interactive === false + ? undefined + : (i) => { + setIsAll(false); + setActiveIndex(i); + }, + isAll, + onAllChange: setIsAll, + iterationStatuses: data.iterationStatuses, + }; + + return ( +
+ {/* Render LoopNode without its native iterator — we overlay V2 nav instead */} + + {/* V2 navigator absolutely positioned into the header right-side area */} +
+
+ +
+
+ {/* Iteration progress strip — 1px hairline at bottom edge of header */} + {progressInfo && ( +
+
+
+ )} +
+ ); +} + +const LOOP_EXECUTION_NODE_TYPES_V2 = { + [LOOP_TYPE]: LoopExecutionCanvasNodeV2, +}; + +function ExecutionStatesV2Story() { + const initialNodes = useMemo(() => createExecutionStateGridV2(), []); + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, + }); + + return ( + + + + + + + ); +} + +export const Anatomy: Story = { + name: 'Anatomy', + render: () => , +}; + +export const Default: Story = { + render: () => , +}; + +export const NestedOuterOutputInsert: Story = { + render: () => , +}; + +export const NestedOuterOutputAppend: Story = { + render: () => , +}; + +export const ExecutionStates: Story = { + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => LOOP_EXECUTION_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + +export const ExecutionStatesV2: Story = { + name: 'Execution States V2 — Compound Picker', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => LOOP_EXECUTION_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx index 45ab4b908..3aefbc033 100644 --- a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx +++ b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx @@ -1,5 +1,5 @@ import { CanvasIcon, ExecutionStatusIcon } from '@uipath/apollo-react/canvas'; -import { memo } from 'react'; +import { createContext, memo, useContext } from 'react'; import type { NodeAdornments, NodeStatusContext } from '../components'; import { CanvasTooltip } from '../components/CanvasTooltip'; import { getExecutionStatusColor } from '../components/ExecutionStatusIcon/ExecutionStatusIcon'; @@ -57,7 +57,7 @@ function ExecutionStatusIndicatorInternal({ status, count }: { status?: string; ); } -function SquareDashedIndicator() { +export function SquareDashedIndicator() { return ( @@ -143,3 +143,20 @@ export function resolveAdornments( // TODO: for now, always return default adornments return getDefaultAdornments(context, options); } + +// --------------------------------------------------------------------------- +// Optional per-story resolver override +// When AdornmentResolverProvider wraps a story, BaseNode uses that resolver +// instead of the default resolveAdornments. The original BaseNode story never +// wraps with this provider, so it is completely unaffected. +// --------------------------------------------------------------------------- + +type AdornmentResolverFn = (context: NodeStatusContext) => NodeAdornments; + +const AdornmentResolverContext = createContext(null); + +export const AdornmentResolverProvider = AdornmentResolverContext.Provider; + +export function useAdornmentResolver(): AdornmentResolverFn { + return useContext(AdornmentResolverContext) ?? resolveAdornments; +} From 6545043cebd778d7e1ba40e5a402fb1be283bebf Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 15:03:29 -0700 Subject: [PATCH 04/16] fix(storybook): reposition iteration progress strip under picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip is now a 1px flex column child inside the navigator overlay div, sitting directly below the iteration picker at the same width — instead of a full-width absolute bar spanning the entire loop node header. Anatomy page updated to match: live demo and strip-states section both reflect the new layout. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 80 +++++++++---------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index c94d92570..7988c1029 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -925,16 +925,22 @@ function AnatomyStory() {
Live demo
- { setDemoIsAll(false); setDemoIndex(i); }, - isAll: demoIsAll, - onAllChange: setDemoIsAll, - iterationStatuses: DEMO_ITERATION_STATUSES, - }} - /> +
+ { setDemoIsAll(false); setDemoIndex(i); }, + isAll: demoIsAll, + onAllChange: setDemoIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> + {/* 1px progress strip sits directly below the picker */} +
+
+
+
@@ -966,14 +972,14 @@ function AnatomyStory() {

Iteration Progress Strip

- A 3 px bar at the bottom edge of the loop header shows how many iterations have - reached a terminal state (Completed, Failed, or Cancelled) as a fraction of the - total. The fill turns red when any iteration has failed; green otherwise. + A 1 px line directly under the iteration picker showing how many iterations have + reached a terminal state (Completed, Failed, or Cancelled) as a fraction of total. + Turns red if any iteration failed; green otherwise.

Strip states
-
+
{([ { label: '0 / 8 — not started', pct: 0, hasFailed: false }, { label: '3 / 8 — in progress', pct: 3 / 8, hasFailed: false }, @@ -982,12 +988,11 @@ function AnatomyStory() { ] as const).map(({ label, pct, hasFailed }) => (
{label}
-
+
0 ? '#22c55e' : 'transparent', - borderRadius: 3, }} />
@@ -1364,7 +1369,7 @@ function LoopExecutionCanvasNodeV2(props: NodeProps {/* Render LoopNode without its native iterator — we overlay V2 nav instead */} - {/* V2 navigator absolutely positioned into the header right-side area */} + {/* V2 navigator + progress strip, stacked in a column under the same anchor */}
+ {/* 1px strip — same width as the picker, sits directly below it */} + {progressInfo && ( +
+
+
+ )}
- {/* Iteration progress strip — 1px hairline at bottom edge of header */} - {progressInfo && ( -
-
-
- )}
); } From 45a2f1769d7050e44f9da973a6418594d5c92313 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 15:22:12 -0700 Subject: [PATCH 05/16] fix(storybook): refine LoopNode V2 picker UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove iteration progress strip — status dot and All aggregate already carry the same information - Change jump-to-failed icon from circle-alert to crosshair so it reads as navigation rather than a duplicate status indicator - Suppress jump-to-failed button when overall loop status is Failed (topRight adornment already communicates it) - Make aggregate badge (✓/✗) clickable to exit All mode — same as clicking All again, with hover accent border as affordance - Fix 1px vertical misalignment: V2_OVERLAY_TOP 9 → 10 so All, picker, and Sequential badge share the same center line Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 124 +++++------------- 1 file changed, 34 insertions(+), 90 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 7988c1029..5f20e12aa 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -668,7 +668,7 @@ const PICKER_DOCS = [ { ctrl: '►', label: 'Step forward', desc: 'Moves one iteration forward. Disabled at the end.' }, { ctrl: '►|', label: 'Jump last', desc: 'Jumps to the final iteration.' }, { ctrl: 'All', label: 'All toggle', desc: 'Collapses into an aggregate view. With status data shows ✓ completed ✗ failed; otherwise Σ total. Click again to return to individual view.' }, - { ctrl: '⚠', label: 'Jump to failed', desc: 'Visible only when at least one iteration has a Failed status. Jumps directly to the first failed iteration.' }, + { ctrl: '⊕', label: 'Jump to failed', desc: 'Visible only when at least one iteration has a Failed status and the loop itself has not globally failed. Jumps directly to the first failed iteration.' }, ] as const; // Demo data for anatomy live demo @@ -925,22 +925,16 @@ function AnatomyStory() {
Live demo
-
- { setDemoIsAll(false); setDemoIndex(i); }, - isAll: demoIsAll, - onAllChange: setDemoIsAll, - iterationStatuses: DEMO_ITERATION_STATUSES, - }} - /> - {/* 1px progress strip sits directly below the picker */} -
-
-
-
+ { setDemoIsAll(false); setDemoIndex(i); }, + isAll: demoIsAll, + onAllChange: setDemoIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + />
@@ -968,39 +962,6 @@ function AnatomyStory() {
- {/* Subsection: Iteration Progress Strip */} -
-

Iteration Progress Strip

-

- A 1 px line directly under the iteration picker showing how many iterations have - reached a terminal state (Completed, Failed, or Cancelled) as a fraction of total. - Turns red if any iteration failed; green otherwise. -

- -
-
Strip states
-
- {([ - { label: '0 / 8 — not started', pct: 0, hasFailed: false }, - { label: '3 / 8 — in progress', pct: 3 / 8, hasFailed: false }, - { label: '5 / 8 — partial run with failure', pct: 5 / 8, hasFailed: true }, - { label: '8 / 8 — all iterations complete', pct: 1, hasFailed: false }, - ] as const).map(({ label, pct, hasFailed }) => ( -
-
{label}
-
-
0 ? '#22c55e' : 'transparent', - }} /> -
-
- ))} -
-
-
-
@@ -1023,6 +984,7 @@ interface LoopIterationStateV2 { isAll: boolean; onAllChange: (isAll: boolean) => void; iterationStatuses?: Map; + overallStatus?: ElementStatusValues; } function stopV2Event(e: React.SyntheticEvent) { @@ -1071,7 +1033,7 @@ function getIterationStatusColor(status: string | undefined): string { } function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { - const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses } = state; + const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(''); const inputRef = useRef(null); @@ -1178,8 +1140,16 @@ function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { {isAll ? ( - /* Aggregate badge — richer ✓/✗ breakdown when status data available */ - + /* Aggregate badge — clickable to exit All mode */ + ) : ( /* Compound picker: |◄ ◄ [index/total] ► ►| */
)} - {/* Jump-to-failed — only visible when a failed iteration exists */} - {firstFailedIndex !== undefined && !isAll && canInteract && ( + {/* Jump-to-failed — hidden when the loop itself is already in Failed state (adornment covers it) */} + {firstFailedIndex !== undefined && !isAll && canInteract && overallStatus !== ElementStatusValues.Failed && ( )}
@@ -1286,6 +1256,7 @@ type LoopExecutionNodeDataV2 = LoopNodeData & { total: number; interactive?: boolean; iterationStatuses?: Map; + status?: ElementStatusValues; }; // Realistic per-iteration status data for each V2 execution demo node @@ -1301,7 +1272,7 @@ const LOOP_EXECUTION_ITERATION_STATUSES = new Map>([ ]); function createExecutionStateGridV2(): Node[] { - return LOOP_EXECUTION_CASES.map(({ id, label, initialIndex, total, parallel, interactive }, index) => { + return LOOP_EXECUTION_CASES.map(({ id, label, initialIndex, total, parallel, interactive, status }, index) => { const colIndex = index % 2; const rowIndex = Math.floor(index / 2); return { @@ -1318,6 +1289,7 @@ function createExecutionStateGridV2(): Node[] { total, interactive, iterationStatuses: LOOP_EXECUTION_ITERATION_STATUSES.get(id), + status, }, style: LOOP_EXECUTION_SIZE, }; @@ -1326,7 +1298,7 @@ function createExecutionStateGridV2(): Node[] { // V2 HEADER_OVERLAY_TOP: aligns with header's pt-2.5 (10px) to vertically center in the h-6 content row // V2 HEADER_OVERLAY_RIGHT: clears the Sequential/Parallel badge (~105px) + gap-2 (8px) + px-3.5 (14px) -const V2_OVERLAY_TOP = 9; +const V2_OVERLAY_TOP = 10; const V2_OVERLAY_RIGHT = 130; function LoopExecutionCanvasNodeV2(props: NodeProps>) { @@ -1341,15 +1313,6 @@ function LoopExecutionCanvasNodeV2(props: NodeProps { - if (!data.iterationStatuses || data.iterationStatuses.size === 0) return null; - const vals = [...data.iterationStatuses.values()]; - const completed = vals.filter(s => s === 'Completed').length; - const failed = vals.filter(s => s === 'Failed').length; - const terminated = completed + failed + vals.filter(s => s === 'Cancelled').length; - return { pct: terminated / data.total, hasFailed: failed > 0 }; - }, [data.iterationStatuses, data.total]); - const iterationState: LoopIterationStateV2 = { activeIndex, total: data.total, @@ -1363,41 +1326,22 @@ function LoopExecutionCanvasNodeV2(props: NodeProps {/* Render LoopNode without its native iterator — we overlay V2 nav instead */} - {/* V2 navigator + progress strip, stacked in a column under the same anchor */}
-
- -
- {/* 1px strip — same width as the picker, sits directly below it */} - {progressInfo && ( -
-
-
- )} +
); @@ -1421,7 +1365,7 @@ function ExecutionStatesV2Story() { ); From 29f731e158e22f82bebb3813ed1c14ce26989701 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 15:36:26 -0700 Subject: [PATCH 06/16] docs(storybook): add adornment slot diagram to LoopNode V2 anatomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the same callout diagram as BaseNode V2 — LoopNode mock with left/right slot labels and connector lines — so both anatomy pages are consistent. Also corrects the bottomRight slot description: LoopNode does not carry a loop count badge (that lives on child BaseNodes). bottomRight only shows the output-pinned indicator. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 5f20e12aa..7ff511ba1 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -650,7 +650,7 @@ const LOOP_SLOT_DOCS = [ { slot: 'topLeft', dot: 'bg-red-500', rule: 'Breakpoint', detail: 'Debug mode — pauses execution at this node.' }, { slot: 'topRight', dot: 'bg-emerald-500', rule: 'Status › Validation error › Warning', detail: 'First matching state wins.' }, { slot: 'bottomLeft', dot: 'bg-blue-500', rule: 'Execution start point', detail: 'Marks the entry node for the current run.' }, - { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Loop count (> 1) › Output pinned', detail: 'Loop count takes priority when both are active.' }, + { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Output pinned', detail: 'Shown when the node output is mocked/pinned. LoopNode does not carry a loop count badge — that appears on child nodes.' }, ] as const; const LOOP_HANDLE_DOCS = [ @@ -753,14 +753,73 @@ function AnatomyStory() {
{/* ── Adornment Slots ── */} -
+

Adornment Slots

- LoopNode uses the same four 20×20 px corner slots as BaseNode, positioned on the outer - container. Priority rules are identical — see BaseNode V2 → Anatomy for the diagram. + Four 20×20 px slots at each corner of the outer container. Slots are only visible + when execution, debug, or validation state is active — they are hidden at rest.

+ + {/* Diagram */} +
+
+ + {/* Left labels — topLeft, bottomLeft */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ + {/* LoopNode mock */} +
+
+
+ + For Each item +
+
+
+ {/* Corner slot indicators */} +
+
+
+
+
+ + {/* Right labels — topRight, bottomRight */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))} +
+ +
+
+
From 98d3d622049ed129837938c87242bcfbf55f1ff0 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:27:41 -0700 Subject: [PATCH 07/16] feat(storybook): add Option B unified pill to LoopNode V2 anatomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second interactive prototype under V2 Iterations for reviewer comparison. Option B folds the All toggle into the left segment of a single pill — removing the first/last jump buttons and reducing the control to one cohesive element. A side-by-side comparison table calls out the key tradeoffs between Option A and Option B. Option A (compound picker) is unchanged and labeled as current. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 300 +++++++++++++++++- 1 file changed, 294 insertions(+), 6 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 7ff511ba1..18cadcda7 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -684,6 +684,8 @@ const DEMO_TOTAL = 8; function AnatomyStory() { const [demoIndex, setDemoIndex] = useState(0); const [demoIsAll, setDemoIsAll] = useState(false); + const [demoIndexB, setDemoIndexB] = useState(0); + const [demoIsAllB, setDemoIsAllB] = useState(false); return (
@@ -973,13 +975,18 @@ function AnatomyStory() {
- {/* Subsection: Compound Iteration Picker */} + {/* Subsection: Option A */}
-

Compound Iteration Picker

-

- Replaces the original single ◄ / ► controls with a compound picker and an "All" - aggregate toggle. Try it below. -

+
+
+

Option A — Compound Picker

+ current +
+

+ "All" as a separate chip alongside a compound picker with first / prev / next / last + buttons and a click-to-edit fraction. All controls visible at all times. +

+
Live demo
@@ -1021,6 +1028,65 @@ function AnatomyStory() {
+ {/* Subsection: Option B */} +
+
+
+

Option B — Unified Segmented Pill

+ proposal +
+

+ "All" becomes the left segment of a single pill, divided from the navigation by a + hairline. First and last jump buttons are removed — the click-to-edit fraction + handles large jumps. Fewer elements, one cohesive control. +

+
+ +
+
Live demo
+
+ { setDemoIsAllB(false); setDemoIndexB(i); }, + isAll: demoIsAllB, + onAllChange: setDemoIsAllB, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ + {/* A vs B comparison */} +
+
+ + + + + + + + + {([ + { aspect: '"All" placement', a: 'Separate chip', b: 'Left segment of pill' }, + { aspect: 'First / last jump', a: '|◄ and ►| buttons', b: 'Removed — use click-to-type' }, + { aspect: 'Element count', a: '3 elements + ⊕', b: '1 pill + ⊕' }, + { aspect: 'Discoverability', a: 'High — all controls shown', b: 'Medium — no first/last shortcut' }, + { aspect: 'Visual weight', a: 'Higher', b: 'Lower' }, + ] as const).map(({ aspect, a, b }, i, arr) => ( + + + + + + ))} + +
AspectOption AOption B
{aspect}{a}{b}
+
+
+
@@ -1310,6 +1376,228 @@ function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { ); } +// ============================================================================ +// Option B: Unified Segmented Pill +// All is the leftmost segment inside a single pill — no first/last jump buttons. +// Click-to-type on the fraction handles large jumps. +// ============================================================================ + +function IterationNavigatorPill({ state }: { state: LoopIterationStateV2 }) { + const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const canInteract = !disabled && typeof onActiveIndexChange === 'function'; + const visibleIndex = activeIndex + 1; + const clampToRange = (v: number) => Math.max(1, Math.min(total, v)); + + const currentStatus = iterationStatuses?.get(activeIndex); + const firstFailedIndex = iterationStatuses + ? [...iterationStatuses.entries()].find(([, s]) => s === 'Failed')?.[0] + : undefined; + const completedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Completed').length + : undefined; + const failedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Failed').length + : 0; + + const handlePrev = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex > 0) onActiveIndexChange?.(activeIndex - 1); + }; + + const handleNext = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex < total - 1) onActiveIndexChange?.(activeIndex + 1); + }; + + const toggleAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onAllChange(!isAll); + }; + + const handleJumpToFailed = (e: React.MouseEvent) => { + e.stopPropagation(); + if (firstFailedIndex !== undefined) onActiveIndexChange?.(firstFailedIndex); + }; + + const startEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!canInteract || isAll || isEditing) return; + setInputValue(String(visibleIndex)); + setIsEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }; + + const commitEdit = () => { + const parsed = parseInt(inputValue, 10); + if (!Number.isNaN(parsed)) onActiveIndexChange?.(clampToRange(parsed) - 1); + setIsEditing(false); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') setIsEditing(false); + }; + + const canGoPrev = canInteract && !isAll && activeIndex > 0; + const canGoNext = canInteract && !isAll && activeIndex < total - 1; + + return ( +
+ {/* Single unified pill */} +
+ + {/* Left segment — All toggle */} + + + {/* Divider */} +
+ + {/* Right segment — aggregate or navigation */} + {isAll ? ( + + ) : ( +
+ {/* Prev */} + + + {/* Editable fraction with status dot */} + + {isEditing ? ( + <> + setInputValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleInputKeyDown} + onPointerDown={stopV2Event} + className="w-7 appearance-none bg-transparent text-center text-[11px] font-semibold leading-none outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none border-b border-foreground-accent" + /> + / + {total} + + ) : ( + <> + {currentStatus && ( + + )} + {visibleIndex} + / + {total} + + )} + + + {/* Next */} + +
+ )} +
+ + {/* Jump-to-failed shortcut — hidden when loop is globally Failed */} + {firstFailedIndex !== undefined && !isAll && canInteract && overallStatus !== ElementStatusValues.Failed && ( + + )} +
+ ); +} + type LoopExecutionNodeDataV2 = LoopNodeData & { initialIndex: number; total: number; From fc952f36c3eb800e4e1e3f06394feb6e608b55bf Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:28:56 -0700 Subject: [PATCH 08/16] docs(storybook): label Option A as current proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both picker options are proposals — updated the badge from "current" to "current proposal" to reflect that. Co-Authored-By: Claude Sonnet 4.6 --- .../src/canvas/components/LoopNode/LoopNodeV2.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 18cadcda7..4333a008b 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -980,7 +980,7 @@ function AnatomyStory() {

Option A — Compound Picker

- current + current proposal

"All" as a separate chip alongside a compound picker with first / prev / next / last From 0c5cb9027dac27bdc6aa8a9539f84a78a19346e1 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:34:33 -0700 Subject: [PATCH 09/16] docs(storybook): display Option A and B side by side in anatomy Replaces the stacked layout with a 2-column grid so reviewers can directly compare the two iteration picker proposals. Each column has a heading, short description, live interactive demo, and a one-line summary. The tradeoff table and Option A control reference sit full-width below. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 146 +++++++++--------- 1 file changed, 76 insertions(+), 70 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 4333a008b..c9339e45e 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -975,22 +975,22 @@ function AnatomyStory() {

- {/* Subsection: Option A */} -
-
-
-

Option A — Compound Picker

- current proposal + {/* Side-by-side comparison */} +
+ + {/* Column A */} +
+
+
+

Option A

+ current proposal +
+

+ "All" as a separate chip alongside a compound picker with first / prev / next / + last buttons. All controls visible at all times. +

-

- "All" as a separate chip alongside a compound picker with first / prev / next / last - buttons and a click-to-edit fraction. All controls visible at all times. -

-
- -
-
Live demo
-
+
-
- -
- - - - - - - - - - {PICKER_DOCS.map(({ ctrl, label, desc }, i) => ( - - - - - - ))} - -
ControlLabelBehaviour
- {ctrl} - {label}{desc}
-
-
- - {/* Subsection: Option B */} -
-
-
-

Option B — Unified Segmented Pill

- proposal -
-

- "All" becomes the left segment of a single pill, divided from the navigation by a - hairline. First and last jump buttons are removed — the click-to-edit fraction - handles large jumps. Fewer elements, one cohesive control. +

+ Compound Picker — separate chip + for All, four navigation buttons, click-to-type fraction.

-
-
Live demo
-
+ {/* Column B */} +
+
+
+

Option B

+ proposal +
+

+ "All" becomes the left segment of a single pill. First and last jump buttons + removed — click-to-type handles large jumps. +

+
+
+

+ Unified Segmented Pill — one + cohesive control, lower visual weight, All integrated as a segment. +

- {/* A vs B comparison */} +
+ + {/* Tradeoff comparison table — full width */} +
+ + + + + + + + + + {([ + { aspect: '"All" placement', a: 'Separate chip', b: 'Left segment of pill' }, + { aspect: 'First / last jump', a: '|◄ and ►| buttons', b: 'Removed — use click-to-type' }, + { aspect: 'Element count', a: '3 elements + ⊕', b: '1 pill + ⊕' }, + { aspect: 'Discoverability', a: 'High — all controls shown', b: 'Medium — no first/last shortcut' }, + { aspect: 'Visual weight', a: 'Higher', b: 'Lower' }, + ] as const).map(({ aspect, a, b }, i, arr) => ( + + + + + + ))} + +
AspectOption AOption B
{aspect}{a}{b}
+
+ + {/* Option A control reference */} +
+

Option A — Control Reference

- - - + + + - {([ - { aspect: '"All" placement', a: 'Separate chip', b: 'Left segment of pill' }, - { aspect: 'First / last jump', a: '|◄ and ►| buttons', b: 'Removed — use click-to-type' }, - { aspect: 'Element count', a: '3 elements + ⊕', b: '1 pill + ⊕' }, - { aspect: 'Discoverability', a: 'High — all controls shown', b: 'Medium — no first/last shortcut' }, - { aspect: 'Visual weight', a: 'Higher', b: 'Lower' }, - ] as const).map(({ aspect, a, b }, i, arr) => ( - - - - + {PICKER_DOCS.map(({ ctrl, label, desc }, i) => ( + + + + ))} From fcd78e8cf10086781c68167011f0e7da98aade07 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:35:26 -0700 Subject: [PATCH 10/16] docs(storybook): remove proposal badges from Option A and B headings Co-Authored-By: Claude Sonnet 4.6 --- .../src/canvas/components/LoopNode/LoopNodeV2.stories.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index c9339e45e..70679aa60 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -983,8 +983,7 @@ function AnatomyStory() {

Option A

- current proposal -
+

"All" as a separate chip alongside a compound picker with first / prev / next / last buttons. All controls visible at all times. @@ -1013,8 +1012,7 @@ function AnatomyStory() {

Option B

- proposal -
+

"All" becomes the left segment of a single pill. First and last jump buttons removed — click-to-type handles large jumps. From 6cdc2c81f39b1730f0730774a4e152317aeeac11 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:39:25 -0700 Subject: [PATCH 11/16] feat(storybook): add Loop Node V2 Demo story for reviewer preview Duplicates the nested loop layout from NestedOuterOutputAppend and wires it up with the V2 compound iteration picker. Outer loop is in progress (iteration 2 of 5); inner loop has completed all 3 iterations. Both pickers are interactive for review. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 70679aa60..3c1e3f348 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -1722,6 +1722,81 @@ function ExecutionStatesV2Story() { ); } +// ============================================================================ +// Loop Node V2 Demo — nested layout with live V2 compound picker +// Based on NestedOuterOutputAppend, loop nodes carry V2 execution data. +// ============================================================================ + +const V2_DEMO_LOOP_STATUS = new Map([ + ['outer-loop', ElementStatusValues.InProgress], + ['inner-loop', ElementStatusValues.Completed], +]); + +const V2_DEMO_ITERATION_STATUSES = new Map>([ + ['outer-loop', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['inner-loop', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], +]); + +function LoopNodeV2DemoStory() { + const initialNodes = useMemo(() => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 272 }), + { + id: 'outer-loop', + type: LOOP_TYPE, + position: snapPoint({ x: 224, y: 96 }), + data: { + display: { label: 'For Each claim', shape: 'container' as const }, + initialIndex: 1, + total: 5, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('outer-loop'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 896, height: 448 }), + }, + { + id: 'inner-loop', + type: LOOP_TYPE, + position: snapPoint({ x: 160, y: 112 }), + parentId: 'outer-loop', + data: { + display: { label: 'For Each attachment', shape: 'container' as const }, + initialIndex: 2, + total: 3, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('inner-loop'), + status: ElementStatusValues.Completed, + }, + style: snapSize({ width: 544, height: 304 }), + }, + createActivityNode('inner-child', 'Classify attachment', { x: 176, y: 112 }, { parentId: 'inner-loop' }), + createActivityNode('egress', 'Publish results', { x: 1216, y: 272 }), + ], []); + + const initialEdges = useMemo(() => [ + { id: 'ingress-outer', source: 'ingress', sourceHandle: 'output', target: 'outer-loop', targetHandle: 'input' }, + { id: 'outer-inner', source: 'outer-loop', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'inner-loop', targetHandle: 'input' }, + { id: 'inner-child-edge', source: 'inner-loop', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'inner-child', targetHandle: 'input' }, + { id: 'child-inner-edge', source: 'inner-child', sourceHandle: 'output', target: 'inner-loop', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + { id: 'outer-egress', source: 'outer-loop', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'egress', targetHandle: 'input' }, + ], []); + + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, + }); + + return ( + + + + + + + ); +} + export const Anatomy: Story = { name: 'Anatomy', render: () => , @@ -1769,3 +1844,19 @@ export const ExecutionStatesV2: Story = { ], render: () => , }; + +export const LoopNodeV2Demo: Story = { + name: 'Loop Node V2 Demo', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => V2_DEMO_LOOP_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; From 86bfa447b3cffc9392e89d0419c2c1162d47f069 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:50:56 -0700 Subject: [PATCH 12/16] feat(storybook): expand Loop Node V2 Demo to 4-level nested layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire all nodes and loops with full edge connections (initialEdges was previously defined but not passed to useCanvasStory) - Expand to 4 levels deep: Region → City → Street → Property, each with 3 activity nodes and a live V2 iteration picker - Auto-fit view on load via initialAutoLayout so the full diagram is immediately visible - Remove StoryInfoPanel — no longer needed on the demo page - Rename story to "Demo - Option A" in the sidebar Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 152 ++++++++++++++---- 1 file changed, 117 insertions(+), 35 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 3c1e3f348..24d7fbed2 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -1723,76 +1723,158 @@ function ExecutionStatesV2Story() { } // ============================================================================ -// Loop Node V2 Demo — nested layout with live V2 compound picker -// Based on NestedOuterOutputAppend, loop nodes carry V2 execution data. +// Loop Node V2 Demo — 4-level nested loop, 3 activity nodes per loop +// Layout: inner loop at top of each body, 3 nodes in a row below. +// Flow per level: loopN.start → innerLoop → nodeA → nodeB → nodeC → loopN.continue // ============================================================================ +// Stable reference: triggers fitView on first load without repositioning nodes +const fitViewOnLoad = async () => {}; + const V2_DEMO_LOOP_STATUS = new Map([ - ['outer-loop', ElementStatusValues.InProgress], - ['inner-loop', ElementStatusValues.Completed], + ['demo-loop-1', ElementStatusValues.InProgress], + ['demo-loop-2', ElementStatusValues.InProgress], + ['demo-loop-3', ElementStatusValues.InProgress], + ['demo-loop-4', ElementStatusValues.Completed], ]); const V2_DEMO_ITERATION_STATUSES = new Map>([ - ['outer-loop', new Map([[0, 'Completed'], [1, 'InProgress']])], - ['inner-loop', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['demo-loop-1', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['demo-loop-2', new Map([[0, 'InProgress']])], + ['demo-loop-3', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['demo-loop-4', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], ]); function LoopNodeV2DemoStory() { const initialNodes = useMemo(() => [ - createActivityNode('ingress', 'Load records', { x: 32, y: 272 }), + // Start + Finish (canvas level) + createActivityNode('demo-start', 'Load Data', { x: 32, y: 496 }), + createActivityNode('demo-finish', 'Generate Report', { x: 1520, y: 496 }), + + // Loop 1 — For Each Region (outermost, 1200 × 928) + { + id: 'demo-loop-1', + type: LOOP_TYPE, + position: snapPoint({ x: 224, y: 32 }), + data: { + display: { label: 'For Each Region', shape: 'container' as const }, + initialIndex: 1, total: 8, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-1'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 1200, height: 928 }), + }, + + // Loop 2 — For Each City (inside Loop 1, 1040 × 688) + { + id: 'demo-loop-2', + type: LOOP_TYPE, + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-1', + data: { + display: { label: 'For Each City', shape: 'container' as const }, + initialIndex: 0, total: 5, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-2'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 1040, height: 688 }), + }, + + // Loop 3 — For Each Street (inside Loop 2, 880 × 448) { - id: 'outer-loop', + id: 'demo-loop-3', type: LOOP_TYPE, - position: snapPoint({ x: 224, y: 96 }), + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-2', data: { - display: { label: 'For Each claim', shape: 'container' as const }, - initialIndex: 1, - total: 5, - iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('outer-loop'), + display: { label: 'For Each Street', shape: 'container' as const }, + initialIndex: 1, total: 3, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-3'), status: ElementStatusValues.InProgress, }, - style: snapSize({ width: 896, height: 448 }), + style: snapSize({ width: 880, height: 448 }), }, + + // Loop 4 — For Each Property (inside Loop 3, 720 × 208, deepest) { - id: 'inner-loop', + id: 'demo-loop-4', type: LOOP_TYPE, - position: snapPoint({ x: 160, y: 112 }), - parentId: 'outer-loop', + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-3', data: { - display: { label: 'For Each attachment', shape: 'container' as const }, - initialIndex: 2, - total: 3, - iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('inner-loop'), + display: { label: 'For Each Property', shape: 'container' as const }, + initialIndex: 2, total: 3, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-4'), status: ElementStatusValues.Completed, }, - style: snapSize({ width: 544, height: 304 }), + style: snapSize({ width: 720, height: 208 }), }, - createActivityNode('inner-child', 'Classify attachment', { x: 176, y: 112 }, { parentId: 'inner-loop' }), - createActivityNode('egress', 'Publish results', { x: 1216, y: 272 }), + + // Loop 4 nodes (y:96 inside Loop 4) + createActivityNode('demo-4a', 'Inspect', { x: 80, y: 96 }, { parentId: 'demo-loop-4' }), + createActivityNode('demo-4b', 'Assess', { x: 272, y: 96 }, { parentId: 'demo-loop-4' }), + createActivityNode('demo-4c', 'Record', { x: 464, y: 96 }, { parentId: 'demo-loop-4' }), + + // Loop 3 nodes (y:336 inside Loop 3, below Loop 4 which ends at y:288) + createActivityNode('demo-3a', 'Validate Street', { x: 80, y: 336 }, { parentId: 'demo-loop-3' }), + createActivityNode('demo-3b', 'Flag Issues', { x: 272, y: 336 }, { parentId: 'demo-loop-3' }), + createActivityNode('demo-3c', 'Log Report', { x: 464, y: 336 }, { parentId: 'demo-loop-3' }), + + // Loop 2 nodes (y:576 inside Loop 2, below Loop 3 which ends at y:528) + createActivityNode('demo-2a', 'Validate City', { x: 80, y: 576 }, { parentId: 'demo-loop-2' }), + createActivityNode('demo-2b', 'Aggregate Data', { x: 272, y: 576 }, { parentId: 'demo-loop-2' }), + createActivityNode('demo-2c', 'Submit City', { x: 464, y: 576 }, { parentId: 'demo-loop-2' }), + + // Loop 1 nodes (y:816 inside Loop 1, below Loop 2 which ends at y:768) + createActivityNode('demo-1a', 'Compile Region', { x: 80, y: 816 }, { parentId: 'demo-loop-1' }), + createActivityNode('demo-1b', 'Audit Region', { x: 272, y: 816 }, { parentId: 'demo-loop-1' }), + createActivityNode('demo-1c', 'Archive Region', { x: 464, y: 816 }, { parentId: 'demo-loop-1' }), ], []); const initialEdges = useMemo(() => [ - { id: 'ingress-outer', source: 'ingress', sourceHandle: 'output', target: 'outer-loop', targetHandle: 'input' }, - { id: 'outer-inner', source: 'outer-loop', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'inner-loop', targetHandle: 'input' }, - { id: 'inner-child-edge', source: 'inner-loop', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'inner-child', targetHandle: 'input' }, - { id: 'child-inner-edge', source: 'inner-child', sourceHandle: 'output', target: 'inner-loop', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, - { id: 'outer-egress', source: 'outer-loop', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'egress', targetHandle: 'input' }, + // Outer flow + { id: 'e-start-l1', source: 'demo-start', sourceHandle: 'output', target: 'demo-loop-1', targetHandle: 'input' }, + { id: 'e-l1-finish', source: 'demo-loop-1', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-finish', targetHandle: 'input' }, + + // Loop 1 body: start → loop2 → 1a → 1b → 1c → continue + { id: 'e-l1s-l2', source: 'demo-loop-1', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-2', targetHandle: 'input' }, + { id: 'e-l2ok-1a', source: 'demo-loop-2', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-1a', targetHandle: 'input' }, + { id: 'e-1a-1b', source: 'demo-1a', sourceHandle: 'output', target: 'demo-1b', targetHandle: 'input' }, + { id: 'e-1b-1c', source: 'demo-1b', sourceHandle: 'output', target: 'demo-1c', targetHandle: 'input' }, + { id: 'e-1c-l1c', source: 'demo-1c', sourceHandle: 'output', target: 'demo-loop-1', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 2 body: start → loop3 → 2a → 2b → 2c → continue + { id: 'e-l2s-l3', source: 'demo-loop-2', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-3', targetHandle: 'input' }, + { id: 'e-l3ok-2a', source: 'demo-loop-3', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-2a', targetHandle: 'input' }, + { id: 'e-2a-2b', source: 'demo-2a', sourceHandle: 'output', target: 'demo-2b', targetHandle: 'input' }, + { id: 'e-2b-2c', source: 'demo-2b', sourceHandle: 'output', target: 'demo-2c', targetHandle: 'input' }, + { id: 'e-2c-l2c', source: 'demo-2c', sourceHandle: 'output', target: 'demo-loop-2', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 3 body: start → loop4 → 3a → 3b → 3c → continue + { id: 'e-l3s-l4', source: 'demo-loop-3', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-4', targetHandle: 'input' }, + { id: 'e-l4ok-3a', source: 'demo-loop-4', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-3a', targetHandle: 'input' }, + { id: 'e-3a-3b', source: 'demo-3a', sourceHandle: 'output', target: 'demo-3b', targetHandle: 'input' }, + { id: 'e-3b-3c', source: 'demo-3b', sourceHandle: 'output', target: 'demo-3c', targetHandle: 'input' }, + { id: 'e-3c-l3c', source: 'demo-3c', sourceHandle: 'output', target: 'demo-loop-3', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 4 body: start → 4a → 4b → 4c → continue + { id: 'e-l4s-4a', source: 'demo-loop-4', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-4a', targetHandle: 'input' }, + { id: 'e-4a-4b', source: 'demo-4a', sourceHandle: 'output', target: 'demo-4b', targetHandle: 'input' }, + { id: 'e-4b-4c', source: 'demo-4b', sourceHandle: 'output', target: 'demo-4c', targetHandle: 'input' }, + { id: 'e-4c-l4c', source: 'demo-4c', sourceHandle: 'output', target: 'demo-loop-4', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, ], []); const { canvasProps } = useCanvasStory({ initialNodes, + initialEdges, additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, }); return ( - + - ); } @@ -1846,7 +1928,7 @@ export const ExecutionStatesV2: Story = { }; export const LoopNodeV2Demo: Story = { - name: 'Loop Node V2 Demo', + name: 'Demo - Option A', decorators: [ withCanvasProviders({ executionState: { From ee36c579b43e0f61beaf48f0316a07b9b4a845cd Mon Sep 17 00:00:00 2001 From: David Anthony Date: Thu, 14 May 2026 16:53:04 -0700 Subject: [PATCH 13/16] feat(storybook): add add-node and connect UI to Loop Node V2 Demo Wire up the same interactive tooling as other loop stories: drag-to-connect triggers the add-node preview, clicking a handle opens the picker, pane click dismisses preview, and Backspace/Delete removes selected nodes. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index 24d7fbed2..eb215cb0e 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -1746,6 +1746,9 @@ const V2_DEMO_ITERATION_STATUSES = new Map>([ ]); function LoopNodeV2DemoStory() { + const reactFlow = useReactFlow(); + const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd(); + const initialNodes = useMemo(() => [ // Start + Finish (canvas level) createActivityNode('demo-start', 'Load Data', { x: 32, y: 496 }), @@ -1864,14 +1867,53 @@ function LoopNodeV2DemoStory() { { id: 'e-4c-l4c', source: 'demo-4c', sourceHandle: 'output', target: 'demo-loop-4', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, ], []); - const { canvasProps } = useCanvasStory({ + const { canvasProps, nodeTypeRegistry } = useCanvasStory({ initialNodes, initialEdges, additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, }); + const loopPreviewOptions = useMemo( + () => ({ + getManifestForNode: (node: Node) => + node.type ? nodeTypeRegistry.getManifest(node.type) : undefined, + }), + [nodeTypeRegistry] + ); + + const handleHandleAction = useCallback( + (event: CanvasHandleActionEvent) => { + const { handleId, nodeId, position, handleType } = event; + if (!handleId || !nodeId) return; + createAddNodePreview( + nodeId, + handleId, + reactFlow, + position as Position, + handleType === 'input' ? 'target' : 'source', + [], + loopPreviewOptions + ); + }, + [loopPreviewOptions, reactFlow] + ); + + useCanvasEvent('handle:action', handleHandleAction); + + const handlePaneClick = useCallback(() => { + removePreviewFromReactFlow(reactFlow); + }, [reactFlow]); + return ( - + + From e8e70693d99876433d57b46b21d748b4478ee288 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Fri, 15 May 2026 11:09:51 -0700 Subject: [PATCH 14/16] feat(storybook): switch Demo page to Option B pill and default All state - Rename demo story to "Demo - Option B" - Add LoopExecutionCanvasNodePill node type using IterationNavigatorPill - Demo canvas uses LOOP_EXECUTION_NODE_TYPES_PILL so Option A story is unaffected - Default isAll=true so the pill opens in the aggregate (All) state - Also default Option B anatomy demo to All-selected state Co-Authored-By: Claude Sonnet 4.6 --- .../LoopNode/LoopNodeV2.stories.tsx | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index eb215cb0e..cb7c3eda6 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -685,7 +685,7 @@ function AnatomyStory() { const [demoIndex, setDemoIndex] = useState(0); const [demoIsAll, setDemoIsAll] = useState(false); const [demoIndexB, setDemoIndexB] = useState(0); - const [demoIsAllB, setDemoIsAllB] = useState(false); + const [demoIsAllB, setDemoIsAllB] = useState(true); return (

@@ -1702,6 +1702,55 @@ const LOOP_EXECUTION_NODE_TYPES_V2 = { [LOOP_TYPE]: LoopExecutionCanvasNodeV2, }; +function LoopExecutionCanvasNodePill(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState( + Math.max(0, Math.min(data.total - 1, data.initialIndex)) + ); + const [isAll, setIsAll] = useState(true); + + useEffect(() => { + setActiveIndex(Math.max(0, Math.min(data.total - 1, data.initialIndex))); + setIsAll(true); + }, [data.initialIndex, data.total]); + + const iterationState: LoopIterationStateV2 = { + activeIndex, + total: data.total, + onActiveIndexChange: + data.interactive === false + ? undefined + : (i) => { + setIsAll(false); + setActiveIndex(i); + }, + isAll, + onAllChange: setIsAll, + iterationStatuses: data.iterationStatuses, + overallStatus: data.status, + }; + + return ( +
+ +
+ +
+
+ ); +} + +const LOOP_EXECUTION_NODE_TYPES_PILL = { + [LOOP_TYPE]: LoopExecutionCanvasNodePill, +}; + function ExecutionStatesV2Story() { const initialNodes = useMemo(() => createExecutionStateGridV2(), []); const { canvasProps } = useCanvasStory({ @@ -1870,7 +1919,7 @@ function LoopNodeV2DemoStory() { const { canvasProps, nodeTypeRegistry } = useCanvasStory({ initialNodes, initialEdges, - additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_PILL, }); const loopPreviewOptions = useMemo( @@ -1970,7 +2019,7 @@ export const ExecutionStatesV2: Story = { }; export const LoopNodeV2Demo: Story = { - name: 'Demo - Option A', + name: 'Demo - Option B', decorators: [ withCanvasProviders({ executionState: { From 268329272889a6dfe17583b830e522da56dbad88 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Fri, 15 May 2026 15:30:47 -0700 Subject: [PATCH 15/16] docs(storybook): adornment slot Option A/B comparison with full header context - Add Option A (inside border) vs Option B (on border) placement diagrams to both BaseNode V2 and LoopNode V2 anatomy pages - LoopNode V2 diagrams use full interactive header mocks (iteration picker + Sequential badge + real adornment icons) so header crowding is visible - Pros/cons bullet lists per option; two-point trade-off callout covering canvas density and the topRight badge / header crowding tension - Default Option B anatomy demo to All-selected state - Switch Demo page to Option B pill (LoopExecutionCanvasNodePill) and rename story to "Demo - Option B" Co-Authored-By: Claude Sonnet 4.6 --- .../BaseNode/BaseNodeV2.stories.tsx | 173 +++++++++++----- .../LoopNode/LoopNodeV2.stories.tsx | 190 +++++++++++++----- 2 files changed, 263 insertions(+), 100 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx index 72328cdfc..ea24e6454 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx @@ -671,73 +671,138 @@ function AnatomyStory() {

Adornment Slots

- Four 20×20 px slots, inset 6 px from each corner. Each slot runs its priority chain and - renders the first matching condition. + Four 20×20 px slots at each corner. Each slot runs its priority chain and renders the + first matching condition. Two placement options are under consideration.

- {/* Diagram */} -
-
- {/* Left labels */} -
- {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( -
-
-
{slot}
-
{rule}
-
-
-
-
+ {/* Option A / Option B comparison */} +
+ + {/* Option A — inside border */} +
+
+

Option A — Inside border

+
    +
  • Never overflows node bounds — safe for dense canvas layouts where nodes sit close together.
  • +
  • Predictable hit area — all interactions stay within the node boundary.
  • +
  • Competes with node content near corners, reducing usable space.
  • +
  • Badge can visually merge with the node background, reducing contrast.
  • +
+
+
+
+ {/* Left labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ {/* Node mock — inset 6px */} +
+
+
+ +
+
+
+
+
- ))} -
- - {/* Node mock at 96×96 — actual CSS from BaseNode */} -
-
-
- + {/* Right labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))}
-
- -
-
- -
-
- -
-
- -
+
- {/* Right labels */} -
- {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( -
-
-
-
-
-
-
{slot}
-
{rule}
+ {/* Option B — on border (50/50) */} +
+
+

Option B — On border

+
    +
  • Familiar notification badge pattern — immediately recognisable to most users.
  • +
  • Pops clearly against both the node background and the canvas — stronger contrast.
  • +
  • Extends slightly outside node bounds — may overlap adjacent nodes or edges at tight spacing.
  • +
  • Canvas layout needs to account for the overflow margin around each node.
  • +
+
+
+
+ {/* Left labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ {/* Node mock — on border (-10px = -½ × 20px slot size) */} +
+
+
+ +
+
+
+
+
- ))} + {/* Right labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))} +
+
+ +
+ + {/* Key trade-off callout */} +
+ Key trade-off — + Option A is the safer choice when canvas nodes are densely packed, since badges never stray outside the node boundary. Option B is more visually distinct and follows a pattern users already recognise from notification systems, but requires the canvas layout to reserve a small overflow margin around each node to avoid clipping into neighbours.
{/* Reference table */} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index cb7c3eda6..dc0bcd241 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -19,6 +19,12 @@ import { import { DefaultCanvasTranslations } from '../../types'; import { ElementStatusValues } from '../../types/execution'; import type { CanvasHandleActionEvent } from '../../utils'; +import { + BreakpointIndicator, + ExecutionStartPointIndicator, + ExecutionStatusIndicator, + SquareDashedIndicator, +} from '../../utils/adornment-resolver'; import { CanvasIcon } from '../../utils/icon-registry'; import { removePreviewFromReactFlow } from '../../utils/createPreviewNode'; import { snapToGrid } from '../../utils/NodeUtils'; @@ -687,6 +693,12 @@ function AnatomyStory() { const [demoIndexB, setDemoIndexB] = useState(0); const [demoIsAllB, setDemoIsAllB] = useState(true); + // State for the "in context" adornment demos + const [ctxIndex, setCtxIndex] = useState(1); + const [ctxIsAll, setCtxIsAll] = useState(false); + const [ctxIndexB, setCtxIndexB] = useState(1); + const [ctxIsAllB, setCtxIsAllB] = useState(false); + return (
@@ -761,65 +773,151 @@ function AnatomyStory() {

Four 20×20 px slots at each corner of the outer container. Slots are only visible when execution, debug, or validation state is active — they are hidden at rest. + Two placement options are under consideration.

- {/* Diagram */} -
-
- - {/* Left labels — topLeft, bottomLeft */} -
- {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( -
-
-
{slot}
-
{rule}
-
-
-
-
+ {/* Option A / Option B diagram comparison */} +
+ + {/* Option A — fully inside */} +
+
+

Option A — Inside border

+
    +
  • Never overflows node bounds — safe for dense canvas layouts where nodes sit close together.
  • +
  • Predictable hit area — all interactions stay within the node boundary.
  • +
  • Competes with header content near corners, reducing usable space.
  • +
  • Badge can visually merge with the node background, reducing contrast.
  • +
+
+
+
+ {/* Left callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot }) => ( +
+ {slot} +
+
+
+
+
+ ))} +
+ {/* Full-header node mock */} +
+
+
+
+ + For Each item +
+
+ { setCtxIsAll(false); setCtxIndex(i); }, isAll: ctxIsAll, onAllChange: setCtxIsAll, iterationStatuses: new Map([[0,'Completed'],[1,'InProgress']]), overallStatus: undefined }} /> + + Sequential + +
+
+ {/* Adornments — inset 6px */} +
+
+
+
- ))} -
- - {/* LoopNode mock */} -
-
-
- - For Each item + {/* Right callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot }) => ( +
+
+
+
+
+ {slot} +
+ ))}
-
- {/* Corner slot indicators */} -
-
-
-
+
- {/* Right labels — topRight, bottomRight */} -
- {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( -
-
-
-
-
-
-
{slot}
-
{rule}
+ {/* Option B — on the border (50/50) */} +
+
+

Option B — On border

+
    +
  • Familiar notification badge pattern — immediately recognisable to most users.
  • +
  • Pops clearly against both the node background and the canvas — stronger contrast.
  • +
  • Extends slightly outside node bounds — may overlap adjacent nodes or edges at tight spacing.
  • +
  • Canvas layout needs to account for the overflow margin around each node.
  • +
+
+
+
+ {/* Left callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot }) => ( +
+ {slot} +
+
+
+
+
+ ))} +
+ {/* Full-header node mock */} +
+
+
+
+ + For Each item +
+
+ { setCtxIsAllB(false); setCtxIndexB(i); }, isAll: ctxIsAllB, onAllChange: setCtxIsAllB, iterationStatuses: new Map([[0,'Completed'],[1,'InProgress']]), overallStatus: undefined }} /> + + Sequential + +
+
+ {/* Adornments — on border -2px */} +
+
+
+
- ))} + {/* Right callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot }) => ( +
+
+
+
+
+ {slot} +
+ ))} +
+
-
+ +
+ + {/* Key trade-off callout */} +
+

Canvas density — Option A never overflows the node boundary, making it safer when nodes are tightly packed. Option B extends slightly outside, so the canvas layout needs a small reserved margin around each node.

+

Header crowding — Option A places the topRight badge inside the header, competing with the iteration picker and Sequential badge for horizontal space (the component compensates with extra padding). Option B moves the badge onto the border, reducing that internal tension and keeping the header content area cleaner — which directly addresses the concern that the header feels full.

From 89ef95ce2446311059196d95bea0eb86f8c15214 Mon Sep 17 00:00:00 2001 From: David Anthony Date: Mon, 18 May 2026 10:41:55 -0700 Subject: [PATCH 16/16] feat(storybook): add Action Needed state and LoopNode V2 responsive pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseNode V2: - Add ActionNeeded execution state with amber circle adornment (top-right) - Add ActionNeededCanvasNode: renders Take Action button below node label - Add dedicated Action Needed story showing all three shapes fit to view - Document Action Needed in anatomy page with design notes and reference table - Fix circular module TDZ: get BaseNode via useNodeTypesFromRegistry hook instead of direct import (BaseNode → adornment-resolver → canvas/index.ts → HierarchicalCanvas DEFAULT_NODE_TYPES access before init) LoopNode V2: - Add responsive tiers (full ≥400px, compact 260-399px, minimal <260px) to IterationNavigatorV2 and IterationNavigatorPill, driven by props.width - Document responsive behavior in anatomy with breakpoint table and interactive tier mocks Co-Authored-By: Claude Sonnet 4.6 --- .../BaseNode/BaseNodeV2.stories.tsx | 296 +++++++++++++++++- .../LoopNode/LoopNodeV2.stories.tsx | 295 ++++++++++++++--- 2 files changed, 548 insertions(+), 43 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx index ea24e6454..8003112c6 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Column } from '@uipath/apollo-react/canvas/layouts'; -import type { Node } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; import { Panel } from '@uipath/apollo-react/canvas/xyflow/react'; import { Button, Input, Label, Slider, Switch } from '@uipath/apollo-wind'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -12,6 +12,7 @@ import { createNode, StoryInfoPanel, useCanvasStory, + useNodeTypesFromRegistry, withCanvasProviders, } from '../../storybook-utils'; import { DefaultCanvasTranslations } from '../../types'; @@ -915,6 +916,141 @@ function AnatomyStory() {
+
+ + {/* ── Action Needed ── */} +
+
+

Action Needed

+

+ A new execution state where the node requires human input before the process can + continue. Visually borrows from the Pause palette (amber/--color-warning-icon) to + signal a temporary halt that needs attention rather than an error. +

+
+ + {/* Visual preview */} +
+ +
+ + {/* Adornment slot */} +
+
Adornment slot — top-right
+
+ + + +

+ Same corner slot as the standard execution status icon. Amber color matches + Pause — both signal a voluntary stop waiting for intervention. +

+
+
+ + {/* Button */} +
+
Action button — below node
+
+ +

+ Floats 8 px below the node. Dark text on amber satisfies WCAG AA contrast. + Clicking opens the action dialog. +

+
+
+ +
+ + {/* Node mock */} +
+
Full appearance — node + adornment + button
+
+ {[ + { shape: 'circle', borderRadius: '50%', width: 56, height: 56 }, + { shape: 'square', borderRadius: 12, width: 56, height: 56 }, + { shape: 'rectangle', borderRadius: 12, width: 96, height: 56 }, + ].map(({ shape, borderRadius, width, height }) => ( +
+
+ {/* Node body */} +
+ {/* Node icon placeholder */} +
+
+
+ {/* Top-right adornment — amber circle with white hand icon */} + + + +
+ {/* Button below */} + + {shape} +
+ ))} +
+
+ +
+ + {/* Design notes */} +
+

Color — Amber reuses --color-warning-icon, matching the Pause state. This creates a visual language: amber = "waiting on something" — either a system pause or a human action.

+

Contrast — The action button uses text-amber-950 (near-black) on bg-amber-400. This achieves a contrast ratio of ~9:1, well above the WCAG AA threshold of 4.5:1 — resolving the original concern about white text on yellow.

+

Button placement — The button floats below the node rather than inside the header to avoid crowding the label and status icon. This also makes it easy to find by eye when scanning a busy canvas — it breaks the node boundary intentionally as a "call to action" affordance.

+
+ + {/* Reference table */} +
+
AspectOption AOption BControlLabelBehaviour
{aspect}{a}{b}
{ctrl}{label}{desc}
+ + + + + + + + {([ + { prop: 'Icon', value: 'hand (Lucide) — top-right adornment slot' }, + { prop: 'Icon color', value: 'var(--color-warning-icon) — same as Pause' }, + { prop: 'Button bg', value: 'bg-amber-400' }, + { prop: 'Button text', value: 'text-amber-950 (dark, ~9:1 contrast ratio)' }, + { prop: 'Button placement', value: '8 px below node bottom edge, horizontally centered' }, + { prop: 'On click', value: 'Opens action dialog / panel (implementation TBD)' }, + ] as const).map(({ prop, value }, i, arr) => ( + + + + + ))} + +
PropertyValue
{prop}{value}
+
+ + +
); @@ -1033,6 +1169,24 @@ function LoopCountPill({ count }: { count: number }) { ); } +function ActionNeededAdornment() { + return ( + + + + + + ); +} + // Custom resolver for V2: moves the loop count out of the status icon (top-right) // into a dedicated pill slot (bottom-right), leaving the status icon uncluttered. function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments { @@ -1045,6 +1199,10 @@ function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments { typeof executionState === 'object' && executionState?.isExecutionStartPoint; const isOutputPinned = typeof executionState === 'object' && executionState?.isOutputPinned; + if (status === 'ActionNeeded') { + return { topRight: }; + } + const hasValidationError = context.validationState?.validationStatus === ValidationErrorSeverity.ERROR || context.validationState?.validationStatus === ValidationErrorSeverity.CRITICAL; @@ -1079,6 +1237,54 @@ function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments { }; } +function ActionNeededCanvasNode(props: NodeProps>) { + // props.height is set by ReactFlow after measurement; DEFAULT_NODE_SIZE = 96 is the safe fallback + const nodeHeight = props.height ?? 96; + // Get BaseNode via the registry hook to avoid a circular module-initialization dependency. + // Importing BaseNode directly here would trigger: BaseNode.tsx → adornment-resolver → + // canvas/index.ts → HierarchicalCanvas.tsx (DEFAULT_NODE_TYPES = { default: BaseNode }) + // while BaseNode.tsx is still evaluating → TDZ crash. + const nodeTypes = useNodeTypesFromRegistry(); + const NodeComponent = nodeTypes.default as React.ComponentType>>; + return ( + <> + {/* Override type with data.nodeType so BaseNode resolves the correct manifest (shape/icon) */} + + {/* Position relative to ReactFlow's own .react-flow__node wrapper (position:absolute), + so top: nodeHeight + 8 lands exactly 8px below the node's bottom edge */} +
+ +
+ + ); +} + +const ACTION_NEEDED_NODE_TYPES = { + 'uipath.manual-trigger-action': ActionNeededCanvasNode, + 'uipath.blank-node-action': ActionNeededCanvasNode, + 'uipath.agent-action': ActionNeededCanvasNode, +}; + // ============================================================================ // Node Anatomy Diagram // ============================================================================ @@ -1236,6 +1442,7 @@ const ADORNMENT_ROWS = [ { key: 'square-dashed', label: 'Square Dashed (bottom-right)' }, { key: 'all', label: 'All Adornments' }, { key: 'multi-exec', label: 'Multi-execution (count: 5)' }, + { key: 'action-needed', label: 'Action Needed' }, ] as const; function createAdornmentGrid(): Node[] { @@ -1243,10 +1450,11 @@ function createAdornmentGrid(): Node[] { ADORNMENT_ROWS.forEach((row, rowIndex) => { SHAPES.forEach(({ shape, nodeType }, colIndex) => { + const isActionNeeded = row.key === 'action-needed'; nodes.push( createNode({ id: `adorn-${row.key}-${shape}`, - type: nodeType, + type: isActionNeeded ? `${nodeType}-action` : nodeType, position: { x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, @@ -1291,6 +1499,8 @@ function getAdornmentExecutionState(key: string) { }; case 'multi-exec': return { status: 'Completed' as const, count: 5 }; + case 'action-needed': + return { status: 'ActionNeeded' as string }; default: return undefined; } @@ -1330,11 +1540,70 @@ const ADORNMENT_DESCRIPTIONS: { label: string; description: string }[] = [ description: 'Loop pill in the bottom-right corner: repeat icon + count. Separate from the execution status icon (top-right) so both are visible at once.', }, + { + label: 'Action Needed', + description: 'Node requires human input before execution can continue. Hand icon (top-right) uses the same warning amber as Pause. A "Take Action" button appears below the node.', + }, ]; +// Stable module-scope reference: triggers fitView on first load without repositioning nodes +const fitViewOnLoad = async () => {}; + +function ActionNeededStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'action-needed-circle', + type: 'uipath.manual-trigger-action', + position: { x: 200, y: 200 }, + data: { + nodeType: 'uipath.manual-trigger', + version: '1.0.0', + display: { label: 'Trigger', subLabel: 'Action Needed', shape: 'circle' }, + }, + }), + createNode({ + id: 'action-needed-square', + type: 'uipath.blank-node-action', + position: { x: 450, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Task', subLabel: 'Action Needed', shape: 'square' }, + }, + }), + createNode({ + id: 'action-needed-rectangle', + type: 'uipath.agent-action', + position: { x: 700, y: 200 }, + data: { + nodeType: 'uipath.agent', + version: '1.0.0', + display: { label: 'Agent', subLabel: 'Action Needed', shape: 'rectangle' }, + }, + }), + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES }); + + return ( + + + + + + + ); +} + function AdornmentsStory() { const initialNodes = useMemo(() => createAdornmentGrid(), []); - const { canvasProps } = useCanvasStory({ initialNodes }); + const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES }); return ( @@ -1384,6 +1653,27 @@ export const Adornments: Story = { render: () => , }; +export const ActionNeeded: Story = { + name: 'Action Needed', + decorators: [ + (Story) => ( + + + + ), + withCanvasProviders({ + executionState: { + getNodeExecutionState: () => ({ status: 'ActionNeeded' as string }), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + // ============================================================================ // Stacked Treatment Story // ============================================================================ diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx index dc0bcd241..2d8b4a532 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -699,6 +699,10 @@ function AnatomyStory() { const [ctxIndexB, setCtxIndexB] = useState(1); const [ctxIsAllB, setCtxIsAllB] = useState(false); + // State for the responsive behavior section demos + const [respIndex, setRespIndex] = useState(1); + const [respIsAll, setRespIsAll] = useState(false); + return (
@@ -1191,6 +1195,141 @@ function AnatomyStory() { +
+ + {/* ── Responsive Behavior ── */} +
+
+

Responsive Behavior

+

+ The iteration picker adapts to available header width through three size tiers. As a loop + node is resized smaller — or deeply nested loops reduce the visible header width — the + picker progressively removes controls to remain usable without overflowing. +

+

+ Note: LoopNode.tsx{' '} + does not yet implement this logic. These tiers are the intended V2 behavior — to be wired + in once the picker approach is approved. +

+
+ + {/* Breakpoint table */} +
+ + + + + + + + + + {([ + { width: '≥ 400 px', tier: 'full', controls: 'All chip · |◄ ◄ k/N ► ►| · crosshair' }, + { width: '260–399 px', tier: 'compact', controls: 'All chip · k/N (click-to-type) · crosshair' }, + { width: '< 260 px', tier: 'minimal', controls: 'k/N count chip only (read-only)' }, + ] as const).map(({ width, tier, controls }, i, arr) => ( + + + + + + ))} + +
Node widthSize tierControls visible
{width} + {tier} + {controls}
+
+ + {/* Three interactive picker mocks — share a single state so you can compare tiers */} +
+ + {/* Wide — full */} +
+
+
+ full +

Wide — ≥ 400 px

+
+

All navigation controls plus first/last jump buttons.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ + {/* Medium — compact */} +
+
+
+ compact +

Medium — 260–399 px

+
+

Arrows removed; click the fraction to jump directly.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ + {/* Narrow — minimal */} +
+
+
+ minimal +

Narrow — < 260 px

+
+

Count chip only — read-only indicator, no navigation.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ +
+ +
+

Why three tiers? — Deeply nested loops reduce the horizontal space available to the iteration picker. Rather than letting the control overflow or truncate awkwardly, we progressively remove less-critical actions: first/last jump buttons go first (replaced by click-to-type), then navigation arrows entirely. The count chip in minimal mode keeps the iteration position visible at a glance without requiring any interaction.

+

Implementation note — The size tier is computed from props.width in the canvas node wrapper (LoopExecutionCanvasNodePill). The navigator components themselves are unaware of the canvas — they accept an explicit size prop, keeping them independently testable.

+
+ +
+
); @@ -1259,7 +1398,7 @@ function getIterationStatusColor(status: string | undefined): string { } } -function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { +function IterationNavigatorV2({ state, size = 'full' }: { state: LoopIterationStateV2; size?: 'full' | 'compact' | 'minimal' }) { const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(''); @@ -1341,6 +1480,36 @@ function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { const canGoNext = canInteract && !isAll && activeIndex < total - 1; const canGoLast = canInteract && !isAll && activeIndex < total - 1; + if (size === 'minimal') { + return ( +
+
+ {isAll ? ( + completedCount !== undefined ? ( + <> + ✓{completedCount} + {failedCount > 0 && ✗{failedCount}} + + ) : ( + <>Σ{total} + ) + ) : ( + <> + {currentStatus && } + {visibleIndex} + / + {total} + + )} +
+
+ ); + } + return (
- - - + {size === 'full' && ( + + + + )} @@ -1454,9 +1625,11 @@ function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { - - - + {size === 'full' && ( + + + + )} )} @@ -1484,7 +1657,7 @@ function IterationNavigatorV2({ state }: { state: LoopIterationStateV2 }) { // Click-to-type on the fraction handles large jumps. // ============================================================================ -function IterationNavigatorPill({ state }: { state: LoopIterationStateV2 }) { +function IterationNavigatorPill({ state, size = 'full' }: { state: LoopIterationStateV2; size?: 'full' | 'compact' | 'minimal' }) { const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(''); @@ -1548,6 +1721,36 @@ function IterationNavigatorPill({ state }: { state: LoopIterationStateV2 }) { const canGoPrev = canInteract && !isAll && activeIndex > 0; const canGoNext = canInteract && !isAll && activeIndex < total - 1; + if (size === 'minimal') { + return ( +
+
+ {isAll ? ( + completedCount !== undefined ? ( + <> + ✓{completedCount} + {failedCount > 0 && ✗{failedCount}} + + ) : ( + <>Σ{total} + ) + ) : ( + <> + {currentStatus && } + {visibleIndex} + / + {total} + + )} +
+
+ ); + } + return (
) : (
- {/* Prev */} - + {/* Prev — hidden in compact */} + {size === 'full' && ( + + )} {/* Editable fraction with status dot */} - {/* Next */} - + {/* Next — hidden in compact */} + {size === 'full' && ( + + )}
)}
@@ -1778,6 +1985,10 @@ function LoopExecutionCanvasNodeV2(props: NodeProps= 400 ? 'full' : + (props.width ?? 0) >= 260 ? 'compact' : 'minimal'; + return (
{/* Render LoopNode without its native iterator — we overlay V2 nav instead */} @@ -1790,7 +2001,7 @@ function LoopExecutionCanvasNodeV2(props: NodeProps - +
); @@ -1828,6 +2039,10 @@ function LoopExecutionCanvasNodePill(props: NodeProps= 400 ? 'full' : + (props.width ?? 0) >= 260 ? 'compact' : 'minimal'; + return (
@@ -1839,7 +2054,7 @@ function LoopExecutionCanvasNodePill(props: NodeProps - +
);