From 1b713d72a7aee70f5f43ec1ae4c1d796bf47d5e7 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Mar 2026 22:25:24 +1300 Subject: [PATCH] feat: add skipped node visual treatment during execution - Add node-skipped CSS class (opacity 0.35) for skip dimming - Expose skippedNodeIds in Zustand store for reactive CSS updates - Apply node-skipped class in WorkflowCanvas alongside switch-dimmed - Clear skippedNodeIds on workflow stop, reset, and completion Co-Authored-By: Claude Opus 4.6 --- src/app/globals.css | 8 +++++++- src/components/WorkflowCanvas.tsx | 19 ++++++++++++------- src/store/workflowStore.ts | 18 ++++++++++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 0e6c4a21..2b744d5e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -68,7 +68,13 @@ body { transition: opacity 250ms ease-in-out; } -.react-flow__node:not(.switch-dimmed) { +/* Skip dimming - nodes skipped due to optional empty inputs */ +.react-flow__node.node-skipped { + opacity: 0.35; + transition: opacity 250ms ease-in-out; +} + +.react-flow__node:not(.switch-dimmed):not(.node-skipped) { transition: opacity 250ms ease-in-out; } diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index ff3e53c3..9e1ee978 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -272,7 +272,7 @@ export const isPanningRef = { current: false }; export const isDraggingNodeRef = { current: false }; export function WorkflowCanvas() { - const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds } = + const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds } = useWorkflowStore(useShallow((state) => ({ nodes: state.nodes, edges: state.edges, @@ -282,6 +282,7 @@ export function WorkflowCanvas() { navigationTarget: state.navigationTarget, canvasNavigationSettings: state.canvasNavigationSettings, dimmedNodeIds: state.dimmedNodeIds, + skippedNodeIds: state.skippedNodeIds, }))); const onNodesChange = useWorkflowStore((state) => state.onNodesChange); const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange); @@ -337,24 +338,28 @@ export function WorkflowCanvas() { } }, [navigationTarget, nodes, setCenter, setNavigationTarget]); - // Apply dimming className to nodes downstream of disabled Switch outputs + // Apply dimming className to nodes downstream of disabled Switch outputs or skipped by optional inputs const allNodes = useMemo(() => { return nodes.map((node) => { // Never dim Switch or ConditionalSwitch nodes themselves if (node.type === "switch" || node.type === "conditionalSwitch") return node; const isDimmed = dimmedNodeIds.has(node.id); - const dimClass = isDimmed ? "switch-dimmed" : ""; + const isSkipped = skippedNodeIds.has(node.id); + const extraClasses = [ + isDimmed ? "switch-dimmed" : "", + isSkipped ? "node-skipped" : "", + ].filter(Boolean).join(" "); - // Preserve existing className if any, add/remove dimmed class - const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").trim(); - const newClass = dimClass ? `${baseClass} ${dimClass}`.trim() : baseClass; + // Preserve existing className if any, add/remove dimmed/skipped classes + const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").replace(/\bnode-skipped\b/g, "").trim(); + const newClass = extraClasses ? `${baseClass} ${extraClasses}`.trim() : baseClass; // Only create new node object if className changed if (node.className === newClass) return node; return { ...node, className: newClass }; }); - }, [nodes, dimmedNodeIds]); + }, [nodes, dimmedNodeIds, skippedNodeIds]); // Node title mapping for FloatingNodeHeaders const NODE_TITLES: Record = { diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 72bb1786..0c99d09c 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -373,6 +373,9 @@ interface WorkflowStore { // Switch dimming state dimmedNodeIds: Set; + // Skip propagation state (optional empty inputs) + skippedNodeIds: Set; + // Switch dimming actions recomputeDimmedNodes: () => void; @@ -507,6 +510,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Switch dimming initial state dimmedNodeIds: new Set(), + // Skip propagation initial state + skippedNodeIds: new Set(), + setEdgeStyle: (style: EdgeStyle) => { set({ edgeStyle: style }); }, @@ -999,7 +1005,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const abortController = new AbortController(); const isResuming = startFromNodeId === get().pausedAtNodeId; const skippedNodeIds = new Set(); - set({ isRunning: true, pausedAtNodeId: null, currentNodeIds: [], _abortController: abortController }); + set({ isRunning: true, pausedAtNodeId: null, currentNodeIds: [], skippedNodeIds: new Set(), _abortController: abortController }); // Start logging session await logger.startSession(); @@ -1081,6 +1087,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ (node.type === "prompt" && !(nodeData.prompt as string)?.trim()); if (isEmpty) { skippedNodeIds.add(node.id); + set({ skippedNodeIds: new Set(skippedNodeIds) }); logger.info('node.execution', 'Node skipped (optional input empty)', { nodeId: node.id, nodeType: node.type, @@ -1094,6 +1101,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const hasSkippedSource = incomingEdgesForSkip.some((e) => skippedNodeIds.has(e.source)); if (hasSkippedSource) { skippedNodeIds.add(node.id); + set({ skippedNodeIds: new Set(skippedNodeIds) }); if (nodeData.status !== undefined) { get().updateNodeData(node.id, { status: "skipped" } as Partial); } @@ -1255,7 +1263,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } } - set({ isRunning: false, currentNodeIds: [], _abortController: null }); + set({ isRunning: false, currentNodeIds: [], skippedNodeIds: new Set(), _abortController: null }); saveLogSession(); await logger.endSession(); @@ -1278,7 +1286,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ get().updateNodeData(skippedId, { status: "idle" } as Partial); } } - set({ isRunning: false, currentNodeIds: [], _abortController: null }); + set({ isRunning: false, currentNodeIds: [], skippedNodeIds: new Set(), _abortController: null }); saveLogSession(); await logger.endSession(); @@ -1291,7 +1299,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (controller) { controller.abort("user-cancelled"); } - set({ isRunning: false, currentNodeIds: [], _abortController: null }); + set({ isRunning: false, currentNodeIds: [], skippedNodeIds: new Set(), _abortController: null }); }, setMaxConcurrentCalls: (value: number) => { @@ -1826,6 +1834,8 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ viewedCommentNodeIds: new Set(), // Reset dimmed nodes dimmedNodeIds: new Set(), + // Reset skipped nodes + skippedNodeIds: new Set(), }); get().clearSnapshot(); },