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 // Evaluate all rules and update match status
useEffect(() => { useEffect(() => {
// Skip re-evaluation when paused — prevents upstream text from re-setting isMatched
if (nodeData.evaluationPaused) return;
const updatedRules = nodeData.rules.map(rule => ({ const updatedRules = nodeData.rules.map(rule => ({
...rule, ...rule,
isMatched: evaluateRule(incomingText, rule.value, rule.mode) isMatched: evaluateRule(incomingText, rule.value, rule.mode)
@ -44,7 +47,7 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
rules: updatedRules 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 // Ref-based handle positioning — measure actual row DOM positions
const ruleRowRefs = useRef<Record<string, HTMLDivElement | null>>({}); const ruleRowRefs = useRef<Record<string, HTMLDivElement | null>>({});
@ -98,24 +101,24 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
updateNodeInternals(id); updateNodeInternals(id);
}, [ruleCount, id, minHeight, setNodes, updateNodeInternals]); }, [ruleCount, id, minHeight, setNodes, updateNodeInternals]);
// Handle rule value change // Handle rule value change (auto-unpauses evaluation)
const handleRuleValueChange = useCallback( const handleRuleValueChange = useCallback(
(ruleId: string, newValue: string) => { (ruleId: string, newValue: string) => {
const updatedRules = nodeData.rules.map((rule) => const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, value: newValue } : rule rule.id === ruleId ? { ...rule, value: newValue } : rule
); );
updateNodeData(id, { rules: updatedRules }); updateNodeData(id, { rules: updatedRules, evaluationPaused: false });
}, },
[id, nodeData.rules, updateNodeData] [id, nodeData.rules, updateNodeData]
); );
// Handle mode change // Handle mode change (auto-unpauses evaluation)
const handleModeChange = useCallback( const handleModeChange = useCallback(
(ruleId: string, newMode: MatchMode) => { (ruleId: string, newMode: MatchMode) => {
const updatedRules = nodeData.rules.map((rule) => const updatedRules = nodeData.rules.map((rule) =>
rule.id === ruleId ? { ...rule, mode: newMode } : rule rule.id === ruleId ? { ...rule, mode: newMode } : rule
); );
updateNodeData(id, { rules: updatedRules }); updateNodeData(id, { rules: updatedRules, evaluationPaused: false });
}, },
[id, nodeData.rules, updateNodeData] [id, nodeData.rules, updateNodeData]
); );
@ -159,6 +162,22 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
updateNodeData(id, { rules: [...nodeData.rules, newRule] }); updateNodeData(id, { rules: [...nodeData.rules, newRule] });
}, [id, nodeData.rules, updateNodeData]); }, [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) // Handle reorder (move up)
const handleMoveUp = useCallback( const handleMoveUp = useCallback(
(index: number) => { (index: number) => {
@ -181,8 +200,8 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
[id, nodeData.rules, updateNodeData] [id, nodeData.rules, updateNodeData]
); );
// Check if default is matched (no rules matched) // Check if default is matched (no rules matched) — suppress when paused
const defaultMatched = !nodeData.rules.some(r => r.isMatched); const defaultMatched = !nodeData.evaluationPaused && !nodeData.rules.some(r => r.isMatched);
return ( return (
<BaseNode <BaseNode
@ -196,6 +215,22 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
minWidth={260} minWidth={260}
minHeight={minHeight} minHeight={minHeight}
className="bg-teal-950/80 border-teal-600" 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 */} {/* Input handle (left) - text only, aligned with header */}
<Handle <Handle
@ -216,7 +251,9 @@ export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps<Wor
<div className="px-2 py-1"> <div className="px-2 py-1">
{/* Text preview — fixed height, above the handle-aligned area */} {/* Text preview — fixed height, above the handle-aligned area */}
<div className="text-[10px] text-neutral-400 truncate h-5 flex items-center"> <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;</> <>Input: &quot;{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}&quot;</>
) : ( ) : (
"No input connected" "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) // Conditional Switch passthrough — traverse upstream if output is active (matched or default)
if (sourceNode.type === "conditionalSwitch") { if (sourceNode.type === "conditionalSwitch") {
const condData = sourceNode.data as ConditionalSwitchNodeData; 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) // When evaluation is paused, all outputs are active (gate is open)
if (!isActive) return; 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; return;
} }

61
src/store/utils/dimmingUtils.ts

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

3
src/store/workflowStore.ts

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

1
src/types/nodes.ts

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

Loading…
Cancel
Save