@ -596,6 +596,134 @@ function promoteUnavailableGeminiFallbacks(
return { nodes : nextNodes , changed } ;
return { nodes : nextNodes , changed } ;
}
}
const NODE_OUTPUT_KEYS = [
"outputImage" ,
"outputImageRef" ,
"outputVideo" ,
"outputVideoRef" ,
"outputVideoRemoteUrl" ,
"outputAudio" ,
"outputAudioRef" ,
"outputText" ,
"output3dUrl" ,
"savedFilePath" ,
"capturedImage" ,
"capturedImageRef" ,
"image" ,
"imageRef" ,
"video" ,
"videoRef" ,
"audio" ,
"model3d" ,
"glbUrl" ,
"images" ,
"imageRefs" ,
"videos" ,
"videoRefs" ,
"outputItems" ,
] as const ;
function hasUsableOutputValue ( value : unknown ) : boolean {
if ( value === null || value === undefined ) return false ;
if ( typeof value === "string" ) {
const trimmed = value . trim ( ) ;
return trimmed . length > 0 && trimmed !== "[]" ;
}
if ( Array . isArray ( value ) ) return value . some ( hasUsableOutputValue ) ;
return true ;
}
function hasNodeOutput ( data : WorkflowNodeData ) : boolean {
const record = data as Record < string , unknown > ;
return NODE_OUTPUT_KEYS . some ( ( key ) = > hasUsableOutputValue ( record [ key ] ) ) ;
}
function resetTransientExecutionState ( nodes : WorkflowNode [ ] ) : { nodes : WorkflowNode [ ] ; changed : boolean } {
let changed = false ;
const nextNodes = nodes . map ( ( node ) = > {
const data = node . data as WorkflowNodeData ;
const status = ( data as Record < string , unknown > ) . status ;
if ( status !== "loading" && status !== "skipped" ) return node ;
changed = true ;
return {
. . . node ,
data : {
. . . data ,
status : hasNodeOutput ( data ) ? "complete" : "idle" ,
error : null ,
} as WorkflowNodeData ,
} as WorkflowNode ;
} ) ;
return { nodes : nextNodes as WorkflowNode [ ] , changed } ;
}
function numbersMatch ( a : unknown , b : unknown ) : boolean {
return typeof a === "number" && typeof b === "number" && Math . abs ( a - b ) < 0.5 ;
}
function positionMatches ( a : XYPosition | undefined , b : XYPosition | undefined ) : boolean {
return ! ! a && ! ! b && numbersMatch ( a . x , b . x ) && numbersMatch ( a . y , b . y ) ;
}
function isDimensionValueEffective (
node : WorkflowNode ,
axis : "width" | "height" ,
value : number | undefined ,
setAttributes : boolean | "width" | "height" | undefined ,
) : boolean {
if ( value === undefined ) return false ;
if ( ! numbersMatch ( node . measured ? . [ axis ] , value ) ) {
return true ;
}
if (
setAttributes === true ||
setAttributes === axis
) {
return ! numbersMatch ( node [ axis ] , value ) ;
}
return false ;
}
function isNodeChangeEffective (
change : NodeChange < WorkflowNode > ,
nodeById : Map < string , WorkflowNode > ,
) : boolean {
switch ( change . type ) {
case "add" :
case "replace" :
return true ;
case "remove" :
return nodeById . has ( change . id ) ;
case "select" : {
const node = nodeById . get ( change . id ) ;
return ! ! node && node . selected !== change . selected ;
}
case "position" : {
const node = nodeById . get ( change . id ) ;
if ( ! node ) return false ;
const positionChanged = change . position !== undefined && ! positionMatches ( node . position , change . position ) ;
const draggingChanged = typeof change . dragging === "boolean" && node . dragging !== change . dragging ;
return positionChanged || draggingChanged ;
}
case "dimensions" : {
const node = nodeById . get ( change . id ) ;
if ( ! node ) return false ;
const widthChanged = isDimensionValueEffective ( node , "width" , change . dimensions ? . width , change . setAttributes ) ;
const heightChanged = isDimensionValueEffective ( node , "height" , change . dimensions ? . height , change . setAttributes ) ;
const resizing = ( node as WorkflowNode & { resizing? : boolean } ) . resizing ;
const resizingChanged = typeof change . resizing === "boolean" && resizing !== change . resizing ;
return widthChanged || heightChanged || resizingChanged ;
}
default :
return true ;
}
}
const workflowStoreImpl : StateCreator < WorkflowStore > = ( set , get ) = > ( {
const workflowStoreImpl : StateCreator < WorkflowStore > = ( set , get ) = > ( {
nodes : [ ] ,
nodes : [ ] ,
edges : [ ] ,
edges : [ ] ,
@ -829,18 +957,22 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
} ,
} ,
onNodesChange : ( changes : NodeChange < WorkflowNode > [ ] ) = > {
onNodesChange : ( changes : NodeChange < WorkflowNode > [ ] ) = > {
const nodeById = new Map ( get ( ) . nodes . map ( ( node ) = > [ node . id , node ] ) ) ;
const effectiveChanges = changes . filter ( ( change ) = > isNodeChangeEffective ( change , nodeById ) ) ;
if ( effectiveChanges . length === 0 ) return ;
// Only mark as unsaved for meaningful changes (not selection changes)
// Only mark as unsaved for meaningful changes (not selection changes)
const hasMeaningfulChange = changes . some (
const hasMeaningfulChange = effe ctiveC hanges. some (
( c ) = > c . type !== "select" && c . type !== "dimensions"
( c ) = > c . type !== "select" && c . type !== "dimensions"
) ;
) ;
// Track manual changes only for remove operations (not position/selection/dimensions)
// Track manual changes only for remove operations (not position/selection/dimensions)
const hasRemoveChange = changes . some ( ( c ) = > c . type === "remove" ) ;
const hasRemoveChange = effe ctiveC hanges. some ( ( c ) = > c . type === "remove" ) ;
// Undo: capture snapshot on drag start
// Undo: capture snapshot on drag start
const hasDragStart = changes . some (
const hasDragStart = effe ctiveC hanges. some (
( c ) = > c . type === "position" && ( c as { dragging? : boolean } ) . dragging === true
( c ) = > c . type === "position" && ( c as { dragging? : boolean } ) . dragging === true
) ;
) ;
const hasDragEnd = changes . some (
const hasDragEnd = effe ctiveC hanges. some (
( c ) = > c . type === "position" && ( c as { dragging? : boolean } ) . dragging === false
( c ) = > c . type === "position" && ( c as { dragging? : boolean } ) . dragging === false
) ;
) ;
if ( hasDragStart && ! isDragging ) {
if ( hasDragStart && ! isDragging ) {
@ -861,7 +993,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}
}
set ( ( state ) = > ( {
set ( ( state ) = > ( {
nodes : applyNodeChanges ( changes , state . nodes ) ,
nodes : applyNodeChanges ( effe ctiveC hanges, state . nodes ) ,
. . . ( hasMeaningfulChange ? { hasUnsavedChanges : true } : { } ) ,
. . . ( hasMeaningfulChange ? { hasUnsavedChanges : true } : { } ) ,
} ) ) ;
} ) ) ;
@ -1724,7 +1856,17 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if ( controller ) {
if ( controller ) {
controller . abort ( "user-cancelled" ) ;
controller . abort ( "user-cancelled" ) ;
}
}
set ( { isRunning : false , currentNodeIds : [ ] , skippedNodeIds : new Set ( ) , _abortController : null } ) ;
set ( ( state ) = > {
const reset = resetTransientExecutionState ( state . nodes ) ;
return {
nodes : reset.nodes ,
isRunning : false ,
currentNodeIds : [ ] ,
skippedNodeIds : new Set ( ) ,
_abortController : null ,
hasUnsavedChanges : state.hasUnsavedChanges || reset . changed ,
} ;
} ) ;
} ,
} ,
mockTutorialExecution : async ( ) = > {
mockTutorialExecution : async ( ) = > {
@ -2315,6 +2457,14 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
} ;
} ;
}
}
const restoredExecutionState = resetTransientExecutionState ( hydratedWorkflow . nodes as WorkflowNode [ ] ) ;
if ( restoredExecutionState . changed ) {
hydratedWorkflow = {
. . . hydratedWorkflow ,
nodes : restoredExecutionState.nodes ,
} ;
}
// Load cost data for this workflow
// Load cost data for this workflow
const costData = workflow . id ? loadWorkflowCostData ( workflow . id ) : null ;
const costData = workflow . id ? loadWorkflowCostData ( workflow . id ) : null ;
@ -2343,7 +2493,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
? joinBrowserFileSystemPath ( directoryPath , "generations" )
? joinBrowserFileSystemPath ( directoryPath , "generations" )
: null ) ,
: null ) ,
lastSavedAt : savedConfig?.lastSavedAt || null ,
lastSavedAt : savedConfig?.lastSavedAt || null ,
hasUnsavedChanges : promotedModels.changed ,
// Restore cost data
// Restore cost data
incurredCost : costData?.incurredCost || 0 ,
incurredCost : costData?.incurredCost || 0 ,
// Track where imageRefs are valid from
// Track where imageRefs are valid from
@ -2354,6 +2503,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
viewedCommentNodeIds : new Set < string > ( ) ,
viewedCommentNodeIds : new Set < string > ( ) ,
// Dismiss welcome modal after loading a workflow
// Dismiss welcome modal after loading a workflow
showQuickstart : false ,
showQuickstart : false ,
hasUnsavedChanges : promotedModels.changed || restoredExecutionState . changed ,
} ) ;
} ) ;
// Clear snapshot unless explicitly preserving (e.g., AI workflow generation)
// Clear snapshot unless explicitly preserving (e.g., AI workflow generation)