Browse Source

feat(44-03): add dimming utility and store integration

- Created dimmingUtils.ts with computeDimmedNodes function
- DFS traversal finds all downstream nodes of disabled Switch outputs
- Smart cascade: nodes with active inputs stay active
- Added dimmedNodeIds state to workflowStore
- Added recomputeDimmedNodes action with set equality check
- Recomputes on: switch toggle change, edge add/remove, workflow load
- Resets dimmedNodeIds on clearWorkflow
- Added execution skip logic in executeSingleNode
- Dimmed nodes skip execution but preserve previous output
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
971efe55a4
  1. 83
      src/store/utils/dimmingUtils.ts
  2. 51
      src/store/workflowStore.ts

83
src/store/utils/dimmingUtils.ts

@ -0,0 +1,83 @@
import type { WorkflowNode, WorkflowEdge, SwitchNodeData } from "@/types";
/**
* Compute set of node IDs that should be visually dimmed.
* A node is dimmed if ALL its input paths trace back to disabled Switch outputs.
* Smart cascade: if a node has at least one active input from a non-disabled source, it stays active.
*/
export function computeDimmedNodes(
nodes: WorkflowNode[],
edges: WorkflowEdge[]
): Set<string> {
// Step 1: Find all nodes that are downstream of disabled Switch outputs
const potentiallyDimmed = new Set<string>();
nodes.forEach(node => {
if (node.type !== "switch") return;
const switchData = node.data as SwitchNodeData;
if (!switchData.switches) return;
switchData.switches.forEach(sw => {
if (sw.enabled) return; // Only process disabled switches
// Find edges from this disabled output handle
const disabledEdges = edges.filter(
e => e.source === node.id && e.sourceHandle === sw.id
);
// DFS traverse downstream from each disabled edge target
disabledEdges.forEach(edge => {
traverseDownstream(edge.target, edges, potentiallyDimmed);
});
});
});
// Step 2: Smart cascade — remove nodes that have at least one active input
// A node stays active if any incoming edge comes from a source NOT in potentiallyDimmed
// AND that source is not a Switch with a disabled output pointing to this node
const finalDimmed = new Set<string>();
potentiallyDimmed.forEach(nodeId => {
const incomingEdges = edges.filter(e => e.target === nodeId);
// Check if any incoming edge provides an active path
const hasActiveInput = incomingEdges.some(edge => {
// Source is not potentially dimmed — it's an active source
if (!potentiallyDimmed.has(edge.source)) {
// But also check: is this edge coming from a disabled Switch output?
const sourceNode = nodes.find(n => n.id === edge.source);
if (sourceNode?.type === "switch") {
const switchData = sourceNode.data as SwitchNodeData;
const switchEntry = switchData.switches?.find(s => s.id === edge.sourceHandle);
// If this specific switch output is disabled, it's not an active path
if (switchEntry && !switchEntry.enabled) return false;
}
return true; // Active source, not dimmed
}
return false; // Source is also dimmed
});
if (!hasActiveInput) {
finalDimmed.add(nodeId);
}
});
return finalDimmed;
}
/**
* DFS traversal to find all downstream nodes from a starting node.
* Uses visited set for cycle detection.
*/
function traverseDownstream(
nodeId: string,
edges: WorkflowEdge[],
visited: Set<string>
): void {
if (visited.has(nodeId)) return; // Cycle detection
visited.add(nodeId);
edges
.filter(e => e.source === nodeId)
.forEach(edge => traverseDownstream(edge.target, edges, visited));
}

51
src/store/workflowStore.ts

@ -58,6 +58,7 @@ import {
clearNodeImageRefs, clearNodeImageRefs,
} from "./utils/executionUtils"; } from "./utils/executionUtils";
import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs"; import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs";
import { computeDimmedNodes } from "./utils/dimmingUtils";
import { import {
executeAnnotation, executeAnnotation,
executeArray, executeArray,
@ -336,6 +337,12 @@ interface WorkflowStore {
// Canvas navigation settings actions // Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void; updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void;
// Switch dimming state
dimmedNodeIds: Set<string>;
// Switch dimming actions
recomputeDimmedNodes: () => void;
} }
let nodeIdCounter = 0; let nodeIdCounter = 0;
@ -428,6 +435,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// Canvas navigation settings initial state // Canvas navigation settings initial state
canvasNavigationSettings: getCanvasNavigationSettings(), canvasNavigationSettings: getCanvasNavigationSettings(),
// Switch dimming initial state
dimmedNodeIds: new Set<string>(),
setEdgeStyle: (style: EdgeStyle) => { setEdgeStyle: (style: EdgeStyle) => {
set({ edgeStyle: style }); set({ edgeStyle: style });
}, },
@ -480,6 +490,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}, },
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => { updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => {
const node = get().nodes.find((n) => n.id === nodeId);
set((state) => ({ set((state) => ({
nodes: state.nodes.map((node) => nodes: state.nodes.map((node) =>
node.id === nodeId node.id === nodeId
@ -488,6 +499,10 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
) as WorkflowNode[], ) as WorkflowNode[],
hasUnsavedChanges: true, hasUnsavedChanges: true,
})); }));
// Recompute dimming if this is a switch node and switches data changed
if (node?.type === "switch" && "switches" in data) {
get().recomputeDimmedNodes();
}
}, },
removeNode: (nodeId: string) => { removeNode: (nodeId: string) => {
@ -524,6 +539,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const hasMeaningfulChange = changes.some((c) => c.type !== "select"); const hasMeaningfulChange = changes.some((c) => c.type !== "select");
// Track manual changes only for remove operations (not selection) // Track manual changes only for remove operations (not selection)
const hasRemoveChange = changes.some((c) => c.type === "remove"); const hasRemoveChange = changes.some((c) => c.type === "remove");
const hasAddOrRemove = changes.some((c) => c.type === "add" || c.type === "remove");
set((state) => ({ set((state) => ({
edges: applyEdgeChanges(changes, state.edges), edges: applyEdgeChanges(changes, state.edges),
@ -533,6 +549,11 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if (hasRemoveChange) { if (hasRemoveChange) {
get().incrementManualChangeCount(); get().incrementManualChangeCount();
} }
// Recompute dimming when edges are added or removed
if (hasAddOrRemove) {
get().recomputeDimmedNodes();
}
}, },
onConnect: (connection: Connection, edgeDataOverrides?: Record<string, unknown>) => { onConnect: (connection: Connection, edgeDataOverrides?: Record<string, unknown>) => {
@ -550,6 +571,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}; };
}); });
get().incrementManualChangeCount(); get().incrementManualChangeCount();
get().recomputeDimmedNodes();
}, },
addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => { addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => {
@ -906,6 +928,18 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
throw new DOMException('Aborted', 'AbortError'); throw new DOMException('Aborted', 'AbortError');
} }
// Check if node is dimmed (downstream of disabled Switch output)
const dimmedNodeIds = get().dimmedNodeIds;
if (dimmedNodeIds.has(node.id)) {
// Skip execution — node is dimmed
// Keep previous output visible (don't clear node data)
logger.info('workflow.skip', 'Node skipped (downstream of disabled Switch)', {
nodeId: node.id,
nodeType: node.type,
});
return;
}
// Check for pause edges on incoming connections (skip if resuming from this exact node) // Check for pause edges on incoming connections (skip if resuming from this exact node)
const isResumingThisNode = isResuming && node.id === startFromNodeId; const isResumingThisNode = isResuming && node.id === startFromNodeId;
if (!isResumingThisNode) { if (!isResumingThisNode) {
@ -1609,6 +1643,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if (!options?.preserveSnapshot) { if (!options?.preserveSnapshot) {
get().clearSnapshot(); get().clearSnapshot();
} }
// Recompute dimming after loading workflow
get().recomputeDimmedNodes();
}, },
clearWorkflow: () => { clearWorkflow: () => {
@ -1631,6 +1668,8 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
imageRefBasePath: null, imageRefBasePath: null,
// Reset viewed comments when clearing workflow // Reset viewed comments when clearing workflow
viewedCommentNodeIds: new Set<string>(), viewedCommentNodeIds: new Set<string>(),
// Reset dimmed nodes
dimmedNodeIds: new Set<string>(),
}); });
get().clearSnapshot(); get().clearSnapshot();
}, },
@ -2131,6 +2170,18 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
saveCanvasNavigationSettings(settings); saveCanvasNavigationSettings(settings);
}, },
// Switch dimming actions
recomputeDimmedNodes: () => {
const { nodes, edges } = get();
const newDimmed = computeDimmedNodes(nodes, edges);
// Only update if set contents changed (prevent unnecessary rerenders)
const currentDimmed = get().dimmedNodeIds;
if (newDimmed.size !== currentDimmed.size ||
[...newDimmed].some(id => !currentDimmed.has(id))) {
set({ dimmedNodeIds: newDimmed });
}
},
}); });
export const useWorkflowStore = create<WorkflowStore>()(workflowStoreImpl); export const useWorkflowStore = create<WorkflowStore>()(workflowStoreImpl);

Loading…
Cancel
Save