Browse Source

feat: add Clear button to ConditionalSwitch node

Adds an evaluationPaused flag that lets users reset evaluation state
and un-dim all downstream paths. The flag auto-resets on workflow
execution or manual rule edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
fe6a2e5480
  1. 53
      src/components/nodes/ConditionalSwitchNode.tsx
  2. 38
      src/store/utils/connectedInputs.ts
  3. 61
      src/store/utils/dimmingUtils.ts
  4. 3
      src/store/workflowStore.ts
  5. 1
      src/types/nodes.ts

53
src/components/nodes/ConditionalSwitchNode.tsx

@ -25,6 +25,9 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
// Evaluate all rules and update match status
useEffect(() => {
// Skip re-evaluation when paused — prevents upstream text from re-setting isMatched
if (nodeData.evaluationPaused) return;
const updatedRules = nodeData.rules.map(rule => ({
...rule,
isMatched: evaluateRule(incomingText, rule.value, rule.mode)
@ -44,7 +47,7 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
rules: updatedRules
});
}
}, [incomingText, nodeData.rules, nodeData.incomingText, id, updateNodeData]);
}, [incomingText, nodeData.rules, nodeData.incomingText, nodeData.evaluationPaused, id, updateNodeData]);
// Ref-based handle positioning — measure actual row DOM positions
const ruleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
@ -98,24 +101,24 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
updateNodeInternals(id);
}, [ruleCount, id, minHeight, setNodes, updateNodeInternals]);
// Handle rule value change
// Handle rule value change (auto-unpauses evaluation)
const handleRuleValueChange = useCallback(
(ruleId: string, newValue: string) => {
const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, value: newValue } : rule
);
updateNodeData(id, { rules: updatedRules });
updateNodeData(id, { rules: updatedRules, evaluationPaused: false });
},
[id, nodeData.rules, updateNodeData]
);
// Handle mode change
// Handle mode change (auto-unpauses evaluation)
const handleModeChange = useCallback(
(ruleId: string, newMode: MatchMode) => {
const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, mode: newMode } : rule
);
updateNodeData(id, { rules: updatedRules });
updateNodeData(id, { rules: updatedRules, evaluationPaused: false });
},
[id, nodeData.rules, updateNodeData]
);
@ -159,6 +162,22 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
updateNodeData(id, { rules: [...nodeData.rules, newRule] });
}, [id, nodeData.rules, updateNodeData]);
// Clear evaluation state — pauses re-evaluation, resets all matches, un-dims downstream
const handleClear = useCallback(() => {
const clearedRules = nodeData.rules.map(rule => ({
...rule,
isMatched: false,
}));
updateNodeData(id, {
evaluationPaused: true,
incomingText: null,
rules: clearedRules,
});
}, [id, nodeData.rules, updateNodeData]);
// Show clear button when there's evaluation state to clear and not already paused
const showClearButton = !nodeData.evaluationPaused && nodeData.incomingText !== null;
// Handle reorder (move up)
const handleMoveUp = useCallback(
(index: number) => {
@ -181,8 +200,8 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
[id, nodeData.rules, updateNodeData]
);
// Check if default is matched (no rules matched)
const defaultMatched = !nodeData.rules.some(r => r.isMatched);
// Check if default is matched (no rules matched) — suppress when paused
const defaultMatched = !nodeData.evaluationPaused && !nodeData.rules.some(r => r.isMatched);
return (
<BaseNode
@ -196,6 +215,22 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
minWidth={260}
minHeight={minHeight}
className="bg-teal-950/80 border-teal-600"
headerButtons={showClearButton ? (
<div className="relative ml-2 shrink-0 group">
<button
onClick={handleClear}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out flex items-center overflow-hidden group-hover:pr-2 text-neutral-500 group-hover:text-neutral-200 border border-neutral-600"
title="Clear evaluation"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-[9px] whitespace-nowrap max-w-0 group-hover:max-w-[60px] transition-all duration-200 ease-in-out overflow-hidden opacity-0 group-hover:opacity-100">
Clear
</span>
</button>
</div>
) : undefined}
>
{/* Input handle (left) - text only, aligned with header */}
<Handle
@ -216,7 +251,9 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
<div className="px-2 py-1">
{/* Text preview — fixed height, above the handle-aligned area */}
<div className="text-[10px] text-neutral-400 truncate h-5 flex items-center">
{incomingText ? (
{nodeData.evaluationPaused ? (
<span className="text-yellow-400">Evaluation paused</span>
) : incomingText ? (
<>Input: &quot;{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}&quot;</>
) : (
"No input connected"

38
src/store/utils/connectedInputs.ts

@ -232,25 +232,29 @@ export function getConnectedInputsPure(
// Conditional Switch passthrough — traverse upstream if output is active (matched or default)
if (sourceNode.type === "conditionalSwitch") {
const condData = sourceNode.data as ConditionalSwitchNodeData;
const sourceHandle = edge.sourceHandle;
// Find matching rule or check if default
const rule = condData.rules.find(r => r.id === sourceHandle);
const isDefaultHandle = sourceHandle === "default";
// Determine if this output is active
let isActive = false;
if (rule) {
isActive = rule.isMatched;
} else if (isDefaultHandle) {
// Default is active when NO rules match
isActive = !condData.rules.some(r => r.isMatched);
}
// Block non-active outputs (data does not flow through non-matching rules)
if (!isActive) return;
// When evaluation is paused, all outputs are active (gate is open)
if (!condData.evaluationPaused) {
const sourceHandle = edge.sourceHandle;
// Find matching rule or check if default
const rule = condData.rules.find(r => r.id === sourceHandle);
const isDefaultHandle = sourceHandle === "default";
// Determine if this output is active
let isActive = false;
if (rule) {
isActive = rule.isMatched;
} else if (isDefaultHandle) {
// Default is active when NO rules match
isActive = !condData.rules.some(r => r.isMatched);
}
// Block non-active outputs (data does not flow through non-matching rules)
if (!isActive) return;
}
// Active output: ConditionalSwitch is a gate — trigger downstream but don't pass data through
// Active output (or paused): ConditionalSwitch is a gate — trigger downstream but don't pass data through
return;
}

61
src/store/utils/dimmingUtils.ts

@ -36,6 +36,9 @@ export function computeDimmedNodes(
const condData = node.data as ConditionalSwitchNodeData;
if (!condData.rules) return;
// When evaluation is paused, skip dimming entirely — all paths stay active
if (condData.evaluationPaused) return;
// Non-matching rules: their downstream should be dimmed
condData.rules.forEach(rule => {
if (rule.isMatched) return; // Only process non-matching rules
@ -83,20 +86,24 @@ export function computeDimmedNodes(
}
} else if (sourceNode?.type === "conditionalSwitch") {
const condData = sourceNode.data as ConditionalSwitchNodeData;
const rule = condData.rules?.find(r => r.id === edge.sourceHandle);
const isDefaultHandle = edge.sourceHandle === "default";
// Check if this output is inactive
let isInactive = false;
if (rule) {
isInactive = !rule.isMatched;
} else if (isDefaultHandle) {
// Default is inactive when any rule matches
isInactive = condData.rules?.some(r => r.isMatched) ?? false;
}
if (isInactive && edge.targetHandle) {
blockedTypes.add(edge.targetHandle);
// When evaluation is paused, outputs are not inactive
if (!condData.evaluationPaused) {
const rule = condData.rules?.find(r => r.id === edge.sourceHandle);
const isDefaultHandle = edge.sourceHandle === "default";
// Check if this output is inactive
let isInactive = false;
if (rule) {
isInactive = !rule.isMatched;
} else if (isDefaultHandle) {
// Default is inactive when any rule matches
isInactive = condData.rules?.some(r => r.isMatched) ?? false;
}
if (isInactive && edge.targetHandle) {
blockedTypes.add(edge.targetHandle);
}
}
} else if (potentiallyDimmed.has(edge.source) && edge.targetHandle) {
blockedTypes.add(edge.targetHandle);
@ -114,22 +121,24 @@ export function computeDimmedNodes(
const switchEntry = switchData.switches?.find(s => s.id === edge.sourceHandle);
if (switchEntry && !switchEntry.enabled) return false;
}
// Skip non-matching ConditionalSwitch outputs
// Skip non-matching ConditionalSwitch outputs (but not when paused — all outputs active)
if (sourceNode?.type === "conditionalSwitch") {
const condData = sourceNode.data as ConditionalSwitchNodeData;
const rule = condData.rules?.find(r => r.id === edge.sourceHandle);
const isDefaultHandle = edge.sourceHandle === "default";
// Check if this output is inactive
let isInactive = false;
if (rule) {
isInactive = !rule.isMatched;
} else if (isDefaultHandle) {
// Default is inactive when any rule matches
isInactive = condData.rules?.some(r => r.isMatched) ?? false;
if (!condData.evaluationPaused) {
const rule = condData.rules?.find(r => r.id === edge.sourceHandle);
const isDefaultHandle = edge.sourceHandle === "default";
// Check if this output is inactive
let isInactive = false;
if (rule) {
isInactive = !rule.isMatched;
} else if (isDefaultHandle) {
// Default is inactive when any rule matches
isInactive = condData.rules?.some(r => r.isMatched) ?? false;
}
if (isInactive) return false;
}
if (isInactive) return false;
}
// Active input — only counts if it provides a blocked type
return edge.targetHandle ? blockedTypes.has(edge.targetHandle) : false;

3
src/store/workflowStore.ts

@ -110,6 +110,7 @@ async function evaluateAndExecuteConditionalSwitch(
updateNodeData(node.id, {
incomingText,
rules: updatedRules,
evaluationPaused: false,
});
await executeConditionalSwitch(executionCtx);
@ -533,7 +534,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if (node?.type === "switch" && "switches" in data) {
get().recomputeDimmedNodes();
}
if (node?.type === "conditionalSwitch" && "rules" in data) {
if (node?.type === "conditionalSwitch" && ("rules" in data || "evaluationPaused" in data)) {
get().recomputeDimmedNodes();
}
},

1
src/types/nodes.ts

@ -394,6 +394,7 @@ export interface ConditionalSwitchRule {
export interface ConditionalSwitchNodeData extends BaseNodeData {
incomingText: string | null; // Upstream text for evaluation and display
rules: ConditionalSwitchRule[]; // User-defined rules
evaluationPaused?: boolean; // When true, skips rule evaluation and downstream dimming
}
/**

Loading…
Cancel
Save