@@ -70,6 +70,7 @@ import {
7070import { useWorkflowValidationController } from "@/lib/workflow-editor/use-validation-controller" ;
7171import { useWorkflowDataCatalog } from "@/lib/workflow-editor/use-workflow-data-catalog" ;
7272import {
73+ draftDiffersFromDeployed ,
7374 workflowDeploymentAfterSave ,
7475 workflowEditorActions ,
7576} from "@/lib/workflow-editor/editor-actions" ;
@@ -448,6 +449,20 @@ export function WorkflowEditorScreen({
448449 : null ,
449450 ) ;
450451 const dirty = editorHistoryIsDirty ( editorHistory , semanticKey ) ;
452+ // Independent of `dirty` (canvas vs. saved draft): flags the saved draft no
453+ // longer matching what is deployed, which a rollback produces without ever
454+ // touching the canvas or the draft, so `dirty` alone would stay false.
455+ const deployedSemanticKey = useMemo (
456+ ( ) => ( deployed ? semanticKeyForDefinition ( deployed . definition ) : null ) ,
457+ [ deployed ] ,
458+ ) ;
459+ const draftSemanticKey = baselineDraft
460+ ? semanticKeyForDefinition ( baselineDraft )
461+ : null ;
462+ const showDraftDiffersFromDeployed = draftDiffersFromDeployed (
463+ draftSemanticKey ,
464+ deployedSemanticKey ,
465+ ) ;
451466 const runnableTriggerIds = useMemo ( ( ) => {
452467 if ( ! canDispatch || ! deployed ) return new Set < string > ( ) ;
453468 const deployedTypes = new Map (
@@ -474,6 +489,8 @@ export function WorkflowEditorScreen({
474489 structurallyValid : nodesValid ( nodes ) ,
475490 hasDraft : baselineDraft !== null ,
476491 } ) ;
492+ const canResetToDeployed =
493+ canEdit && deployed !== null && semanticKey !== deployedSemanticKey ;
477494
478495 useEffect ( ( ) => {
479496 if ( ! dirty ) return ;
@@ -975,6 +992,36 @@ export function WorkflowEditorScreen({
975992 }
976993 }
977994
995+ // Rollback and "Reset to deployed" both need the canvas to show a specific,
996+ // already-known definition instead of whatever the draft last held. The
997+ // loaded content is compared against the saved draft (not the definition
998+ // just loaded), so a rollback that lands on a different graph than the
999+ // draft correctly shows as unsaved rather than being silently treated as
1000+ // the new baseline.
1001+ function loadDefinitionIntoCanvas ( definition : WorkflowDefinition ) {
1002+ const flow = toFlowDefinition ( definition ) ;
1003+ const nextDocument : WorkflowEditorDocument = {
1004+ nodes : flow . nodes ,
1005+ edges : flow . edges ,
1006+ budgets : executionLimitsFromDefinition ( definition ) ,
1007+ repositoryScope : repositoryScopeFromDefinition ( definition ) ,
1008+ edgeGeometry : structuredClone ( edgeGeometry ) ,
1009+ } ;
1010+ setSchemaVersion ( flow . schemaVersion ) ;
1011+ editorDocumentRef . current = nextDocument ;
1012+ dispatchEditorHistory ( {
1013+ type : "reset" ,
1014+ value : nextDocument ,
1015+ savedSemanticKey : baselineDraft ? semanticKeyForDefinition ( baselineDraft ) : null ,
1016+ } ) ;
1017+ setFitSignal ( ( signal ) => signal + 1 ) ;
1018+ }
1019+
1020+ function resetToDeployed ( ) {
1021+ if ( ! deployed ) return ;
1022+ loadDefinitionIntoCanvas ( deployed . definition ) ;
1023+ }
1024+
9781025 async function rollback ( version : number ) {
9791026 setBusy ( `rollback-${ version } ` ) ;
9801027 setError ( null ) ;
@@ -991,6 +1038,7 @@ export function WorkflowEditorScreen({
9911038 const body = ( await res . json ( ) ) as WorkflowDefinitionDeploymentResponse ;
9921039 setDeployed ( body . deployed ) ;
9931040 setMetas ( ( prev ) => prev . map ( ( meta ) => ( meta . id === body . meta . id ? body . meta : meta ) ) ) ;
1041+ loadDefinitionIntoCanvas ( body . deployed . definition ) ;
9941042 setConfirmRestore ( null ) ;
9951043 } catch ( err ) {
9961044 setError ( err instanceof Error ? err . message : "Unable to roll back version" ) ;
@@ -1258,6 +1306,14 @@ export function WorkflowEditorScreen({
12581306 headerVersionBadge = { deployed ? `deployed v${ deployed . version } ` : "not deployed" }
12591307 headerInlineExtra = {
12601308 < >
1309+ { showDraftDiffersFromDeployed && (
1310+ < span
1311+ title = "The saved draft no longer matches the deployed version. Use Reset to deployed to load what is live into the canvas."
1312+ className = "rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.04em] text-amber-800"
1313+ >
1314+ Draft differs from deployed
1315+ </ span >
1316+ ) }
12611317 { repositoryScopeSummary !== null && (
12621318 < span
12631319 title = "Repositories pinned to this workflow. Every ticket entering it inherits them."
@@ -1301,6 +1357,16 @@ export function WorkflowEditorScreen({
13011357 { busy === "deploy" ? "Deploying…" : "Deploy" }
13021358 </ button >
13031359 ) }
1360+ { canEdit && deployed !== null && (
1361+ < button
1362+ onClick = { resetToDeployed }
1363+ disabled = { ! canResetToDeployed || busy !== null }
1364+ title = "Load the deployed version's nodes and edges into the canvas."
1365+ className = { headerButtonClass }
1366+ >
1367+ Reset to deployed
1368+ </ button >
1369+ ) }
13041370 < button
13051371 onClick = { ( ) => {
13061372 setDefsOpen ( ( o ) => ! o ) ;
0 commit comments