Преглед изворни кода

feat: merge changes between tabs instead of warning that a copy is stale

Telling somebody their copy is out of date and offering a reload is the
weak answer: it asks them to choose between two versions without showing
them either, and it is what made an accidental overwrite possible in the
first place - a canvas that never learned about the other side will
happily save straight over it.

Now every editor takes the other side's work as it arrives, whether or
not it has unsaved work of its own. A three-way merge against the version
the canvas started from keeps both: their new nodes appear, your unsaved
ones stay, and the wiring follows. Once a canvas holds both sets of
changes, saving it cannot lose either - which is the real fix for the
overwrite, rather than a warning asking somebody to be careful.

Where both sides changed the same node there is no honest automatic
answer, so the canvas keeps its own and says which nodes those were. It
never drops the other side silently.

The banner shows what came in and what is still only local, measured
separately against the version this canvas started from - "they added a
node" and "you added a node" are different facts and were previously
reported as one confusing one, where an unsaved node of your own was
described as a node somebody else had deleted.

Two bugs found while testing this, both of which would have lost work:

- The loader ends with setHasChanges(false), which ran after the merge
  and presented a canvas holding unsaved work as saved - so nothing would
  have warned on the way out. It now clears the flag only for a load that
  really does match what is stored.
- Two tabs adding a node each both picked the same next free name, and
  the merge kept both. Names are how an expression refers to a node, so
  the duplicate made {{$node["HTTP Request 2"]}} ambiguous. The stored
  node keeps its name, the local one is renamed, and the rename is
  reported rather than done quietly.

Also: your own second tab now counts. Filtering the save notice by user
id meant a second tab of your own was silently ignored, so it went stale
without being told and its refused save blamed "someone else". The test
is now which version the editor holds, not whose account it is - a second
tab has its own copy and can overwrite the first just as thoroughly as a
colleague can. The header shows a "2 tabs" badge when you have one open.

Verified with two tabs: a clean tab adopts the other's save on its own; a
dirty tab keeps its unsaved node, gains theirs, and saves successfully
afterwards; both-added-the-same-name produces no duplicate; and the
change list names what came from where.
fszontagh пре 1 месец
родитељ
комит
85a7fd1774

+ 10 - 0
webui/src/components/workflow/EditorHeader.tsx

@@ -29,6 +29,7 @@ interface EditorHeaderProps {
   onAddNode: () => void
   onAddNode: () => void
   onAutoLayout: () => void
   onAutoLayout: () => void
   otherViewers?: { userId: string; username: string }[]
   otherViewers?: { userId: string; username: string }[]
+  myOtherTabs?: number
   onUndo: () => void
   onUndo: () => void
   onRedo: () => void
   onRedo: () => void
   canUndo: boolean
   canUndo: boolean
@@ -63,6 +64,7 @@ export function EditorHeader({
   onAddNode,
   onAddNode,
   onAutoLayout,
   onAutoLayout,
   otherViewers = [],
   otherViewers = [],
+  myOtherTabs = 0,
   onUndo,
   onUndo,
   onRedo,
   onRedo,
   canUndo,
   canUndo,
@@ -188,6 +190,14 @@ export function EditorHeader({
         </button>
         </button>
         {/* Who else has this open. Only shown when there is somebody, so a
         {/* Who else has this open. Only shown when there is somebody, so a
             single-user install never sees it. */}
             single-user install never sees it. */}
+        {myOtherTabs > 0 && (
+          <span
+            className="px-2 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 mr-1"
+            title={`You have this workflow open in ${myOtherTabs + 1} places. Each one holds its own copy, so saving in one makes the others out of date.`}
+          >
+            {myOtherTabs + 1} tabs
+          </span>
+        )}
         {otherViewers.length > 0 && (
         {otherViewers.length > 0 && (
           <div
           <div
             className="flex items-center -space-x-2 mr-1"
             className="flex items-center -space-x-2 mr-1"

+ 28 - 6
webui/src/hooks/useWorkflowPresence.ts

@@ -26,9 +26,20 @@ export interface SavedByOther {
   userId: string
   userId: string
   username: string
   username: string
   version: number
   version: number
+  /** Your own account, but a different editor - another tab or another window. */
+  isSelf: boolean
 }
 }
 
 
-export function useWorkflowPresence(workflowId: string | undefined) {
+/**
+ * @param ownVersionRef the record version this editor currently holds, which is
+ *   how it recognises its own saves. Identity is the wrong test: your second
+ *   tab has its own copy of the workflow and can overwrite the first one just
+ *   as thoroughly as a colleague can.
+ */
+export function useWorkflowPresence(
+  workflowId: string | undefined,
+  ownVersionRef?: { current: number | null }
+) {
   const currentUserId = useAuthStore((s) => s.user?.id)
   const currentUserId = useAuthStore((s) => s.user?.id)
 
 
   const [viewers, setViewers] = useState<Viewer[]>([])
   const [viewers, setViewers] = useState<Viewer[]>([])
@@ -50,13 +61,19 @@ export function useWorkflowPresence(workflowId: string | undefined) {
     }
     }
 
 
     const onSaved = (data: any) => {
     const onSaved = (data: any) => {
-      // Your own save is not news, and would otherwise warn you about yourself
-      // every time you pressed the button.
-      if (data?.userId && data.userId === currentUserIdRef.current) return
+      const version = Number(data?.version) || 0
+      const known = ownVersionRef?.current
+
+      // The save this editor just made is not news to it. Anything that moves
+      // the workflow past the version in hand is, whoever made it - which is
+      // what makes your own second tab count, where comparing user ids did not.
+      if (typeof known === 'number' && version <= known) return
+
       setSavedByOther({
       setSavedByOther({
         userId: data?.userId ?? '',
         userId: data?.userId ?? '',
         username: data?.username || 'Someone',
         username: data?.username || 'Someone',
-        version: Number(data?.version) || 0,
+        version,
+        isSelf: !!data?.userId && data.userId === currentUserIdRef.current,
       })
       })
     }
     }
 
 
@@ -76,7 +93,12 @@ export function useWorkflowPresence(workflowId: string | undefined) {
   }, [workflowId])
   }, [workflowId])
 
 
   const others = viewers.filter((v) => v.userId !== currentUserId)
   const others = viewers.filter((v) => v.userId !== currentUserId)
+
+  // Your own other tabs. Worth saying: a second tab holds its own copy and can
+  // overwrite this one, so "nobody else is here" would be a half-truth.
+  const mine = viewers.find((v) => v.userId === currentUserId)
+  const myOtherTabs = Math.max(0, (mine?.connections ?? 1) - 1)
   const dismissSavedByOther = useCallback(() => setSavedByOther(null), [])
   const dismissSavedByOther = useCallback(() => setSavedByOther(null), [])
 
 
-  return { viewers, others, savedByOther, dismissSavedByOther }
+  return { viewers, others, myOtherTabs, savedByOther, dismissSavedByOther }
 }
 }

+ 234 - 35
webui/src/pages/WorkflowEditorPage.tsx

@@ -31,6 +31,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
 import { AlertTriangle } from 'lucide-react'
 import { AlertTriangle } from 'lucide-react'
 import { wsClient } from '../api/client'
 import { wsClient } from '../api/client'
 import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
 import { useWorkflowPresence } from '../hooks/useWorkflowPresence'
+import { diffWorkflows, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
 import type { AvailableField } from '../components/ConditionBuilder'
 import type { AvailableField } from '../components/ConditionBuilder'
 import type { FieldInfo } from '../components/AvailableDataPanel'
 import type { FieldInfo } from '../components/AvailableDataPanel'
 import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
 import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
@@ -350,6 +351,13 @@ function WorkflowEditorInner() {
   const [edges, setEdges, onEdgesChange] = useEdgesState([])
   const [edges, setEdges, onEdgesChange] = useEdgesState([])
   const [showNodePicker, setShowNodePicker] = useState(false)
   const [showNodePicker, setShowNodePicker] = useState(false)
   const [hasChanges, setHasChanges] = useState(false)
   const [hasChanges, setHasChanges] = useState(false)
+  // Read by the sync effect, which must see the value as it is now rather than
+  // as it was when the effect was created.
+  const hasChangesRef = useRef(hasChanges)
+  hasChangesRef.current = hasChanges
+  // Set only by the merge path: the graph about to be loaded is not the stored
+  // one, so the canvas has unsaved work the moment it appears.
+  const loadedIsUnsavedRef = useRef(false)
   const [selectedNodeData, setSelectedNodeData] = useState<SelectedNodeData | null>(null)
   const [selectedNodeData, setSelectedNodeData] = useState<SelectedNodeData | null>(null)
   const [showNodeConfig, setShowNodeConfig] = useState(false)
   const [showNodeConfig, setShowNodeConfig] = useState(false)
   const [editingConfig, setEditingConfig] = useState<Record<string, any>>({})
   const [editingConfig, setEditingConfig] = useState<Record<string, any>>({})
@@ -563,17 +571,117 @@ function WorkflowEditorInner() {
 
 
   // The version currently on the canvas. Kept in a ref because saving reads it
   // The version currently on the canvas. Kept in a ref because saving reads it
   // and must not be re-created every time it changes.
   // and must not be re-created every time it changes.
-  const { others: otherViewers, savedByOther, dismissSavedByOther } = useWorkflowPresence(id)
+  // What the other tab or the other person actually did. Fetched only when
+  // there is something to explain, not kept in step continuously.
+  const [incomingChanges, setIncomingChanges] = useState<Change[] | null>(null)
+  const [myChanges, setMyChanges] = useState<Change[] | null>(null)
+  // Where both sides changed the same thing and this canvas kept its own.
+  const [conflicts, setConflicts] = useState<string[]>([])
+  const [showChanges, setShowChanges] = useState(false)
+  // Whether those changes were taken onto this canvas automatically, or are
+  // still only a warning because taking them would have destroyed something.
+  const [autoSynced, setAutoSynced] = useState(false)
+
+  const loadedVersionRef = useRef<number | null>(null)
+  const { others: otherViewers, myOtherTabs, savedByOther, dismissSavedByOther } =
+    useWorkflowPresence(id, loadedVersionRef)
   // The save handler needs the latest value without being rebuilt for it.
   // The save handler needs the latest value without being rebuilt for it.
   const savedByOtherRef = useRef(savedByOther)
   const savedByOtherRef = useRef(savedByOther)
   savedByOtherRef.current = savedByOther
   savedByOtherRef.current = savedByOther
 
 
-  const loadedVersionRef = useRef<number | null>(null)
-  const [saveConflict, setSaveConflict] = useState<{ username: string; currentVersion: number } | null>(null)
+  const [saveConflict, setSaveConflict] = useState<{ username: string; isSelf: boolean; currentVersion: number } | null>(null)
+  // The workflow as this canvas received it. Everything the user has done since
+  // is the difference between this and the canvas, and everything the other tab
+  // did is the difference between this and what is now stored. Without it, an
+  // unsaved node of your own is indistinguishable from a node somebody else
+  // deleted - and gets reported as the latter.
+  const baselineRef = useRef<{ name?: string; nodes: any[]; connections: any[]; settings?: any } | null>(null)
+  // Which stored version the baseline describes. A merge puts a graph into the
+  // cache that is deliberately NOT the stored one, and without this the
+  // baseline would follow it and every later comparison would be measured from
+  // the wrong place.
+  const baselineVersionRef = useRef<number | null>(null)
   useEffect(() => {
   useEffect(() => {
-    if (typeof workflow?.version === 'number') loadedVersionRef.current = workflow.version
+    if (!workflow) return
+    if (typeof workflow.version === 'number') loadedVersionRef.current = workflow.version
+    if (baselineVersionRef.current === (workflow.version ?? null)) return
+    baselineVersionRef.current = workflow.version ?? null
+    baselineRef.current = {
+      name: workflow.name,
+      nodes: workflow.nodes || [],
+      connections: workflow.connections || [],
+      settings: workflow.settings || {},
+    }
   }, [workflow])
   }, [workflow])
 
 
+  useEffect(() => {
+    if (!id || (!savedByOther && !saveConflict)) {
+      setIncomingChanges(null)
+      setMyChanges(null)
+      setConflicts([])
+      setShowChanges(false)
+      setAutoSynced(false)
+      return
+    }
+    let cancelled = false
+    // currentGraphRef rather than currentGraph, so this does not re-run and
+    // re-fetch on every keystroke on the canvas.
+    workflowsApi
+      .get(id)
+      .then((stored) => {
+        if (cancelled) return
+
+        // Diff first. Adopting the stored version replaces the canvas, and
+        // after that there is nothing left to compare it against.
+        //
+        // Both sides are measured against the version this canvas started
+        // from, not against each other, so "they added a node" and "you added
+        // a node" stay separate facts rather than one confusing one.
+        const baseline = baselineRef.current ?? { nodes: [], connections: [] }
+        const mineNow = currentGraphRef.current()
+        setIncomingChanges(diffWorkflows(baseline as any, stored as any))
+        setMyChanges(diffWorkflows(baseline as any, mineNow))
+
+        // Every tab takes the other side's work, whether or not it has unsaved
+        // work of its own. Leaving a canvas behind is what made an accidental
+        // overwrite possible in the first place: once this one holds both sets
+        // of changes, saving it cannot lose either.
+        const merge = mergeWorkflows(baseline as any, mineNow, stored as any)
+        setConflicts(merge.conflicts)
+        setAutoSynced(true)
+
+        // The baseline is the STORED version, not the merged one - that is the
+        // point the next comparison has to be measured from.
+        baselineVersionRef.current = stored.version ?? null
+        baselineRef.current = {
+          name: stored.name,
+          nodes: stored.nodes || [],
+          connections: stored.connections || [],
+          settings: stored.settings || {},
+        }
+        if (typeof stored.version === 'number') loadedVersionRef.current = stored.version
+
+        // Carrying the stored version on the merged record is what lets the
+        // next save through: it is no longer based on a version that has been
+        // overtaken.
+        // Seeding the cache runs the loader below, which is what puts the
+        // merged graph on the canvas - and which needs telling that this
+        // particular load is not a clean one.
+        loadedIsUnsavedRef.current = merge.differsFromStored
+        queryClient.setQueryData(['workflow', id], { ...stored, ...merge.graph })
+        setHasChanges(merge.differsFromStored)
+        // The refusal has been dealt with - what it was protecting is now
+        // folded in, and pressing save again is the right move.
+        setSaveConflict(null)
+      })
+      .catch(() => {
+        // The comparison is a convenience; failing to fetch it must not take
+        // the warning itself away.
+        if (!cancelled) { setIncomingChanges(null); setMyChanges(null) }
+      })
+    return () => { cancelled = true }
+  }, [id, savedByOther, saveConflict])
+
   const { data: nodeDefinitions } = useQuery({
   const { data: nodeDefinitions } = useQuery({
     queryKey: ['nodes'],
     queryKey: ['nodes'],
     queryFn: () => nodesApi.list(),
     queryFn: () => nodesApi.list(),
@@ -751,11 +859,15 @@ function WorkflowEditorInner() {
       // to it rather than being told "failed" and left to guess.
       // to it rather than being told "failed" and left to guess.
       const conflict = error?.response?.status === 409 ? error.response.data : null
       const conflict = error?.response?.status === 409 ? error.response.data : null
       if (conflict) {
       if (conflict) {
+        const other = savedByOtherRef.current
         setSaveConflict({
         setSaveConflict({
-          username: conflict.current?.updatedByUsername || savedByOtherRef.current?.username || 'Someone else',
+          username: other?.username || 'Someone else',
+          isSelf: other?.isSelf ?? false,
           currentVersion: Number(conflict.currentVersion) || 0,
           currentVersion: Number(conflict.currentVersion) || 0,
         })
         })
-        showToast('error', 'Someone else saved this workflow while you were editing it')
+        showToast('error', other?.isSelf
+          ? 'You saved this workflow in another tab'
+          : 'Someone else saved this workflow while you were editing it')
         return
         return
       }
       }
       showToast('error', `Failed to save: ${error.message}`)
       showToast('error', `Failed to save: ${error.message}`)
@@ -1357,7 +1469,13 @@ function WorkflowEditorInner() {
 
 
       setNodes(flowNodes)
       setNodes(flowNodes)
       setEdges(flowEdges)
       setEdges(flowEdges)
-      setHasChanges(false)
+      // Loading normally means the canvas matches what is stored. A merge is
+      // the exception: it loads a graph that deliberately does not, and
+      // clearing the flag there would present unsaved work as saved - the one
+      // mistake that actually loses it, because nothing then warns on the way
+      // out.
+      setHasChanges(loadedIsUnsavedRef.current)
+      loadedIsUnsavedRef.current = false
     }
     }
   }, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
   }, [workflow, nodeDefs, nodeDefsMap, setNodes, setEdges])
 
 
@@ -1467,8 +1585,11 @@ function WorkflowEditorInner() {
     setShowNodePicker(true)
     setShowNodePicker(true)
   }, [isViewingExecution, reactFlowInstance])
   }, [isViewingExecution, reactFlowInstance])
 
 
-  const handleSave = useCallback(() => {
-    const workflowNodes = nodes.map((node) => ({
+  // What is on the canvas, in the shape the workflow is stored in. Saving and
+  // diffing both need it, and two copies of this mapping would drift.
+  const currentGraph = useCallback(() => ({
+    name: workflowName,
+    nodes: nodes.map((node) => ({
       id: node.id,
       id: node.id,
       type: node.data.type,
       type: node.data.type,
       name: node.data.label,
       name: node.data.label,
@@ -1479,26 +1600,33 @@ function WorkflowEditorInner() {
       // off came back on at the next save, which looks like the editor
       // off came back on at the next save, which looks like the editor
       // refusing to do as it is told.
       // refusing to do as it is told.
       disabled: node.data.disabled === true,
       disabled: node.data.disabled === true,
-    }))
-
-    const workflowConnections = edges.map((edge) => ({
+    })),
+    connections: edges.map((edge) => ({
       sourceNodeId: edge.source,
       sourceNodeId: edge.source,
       sourceOutput: edge.sourceHandle || 'main',
       sourceOutput: edge.sourceHandle || 'main',
       targetNodeId: edge.target,
       targetNodeId: edge.target,
       targetInput: edge.targetHandle || 'data',
       targetInput: edge.targetHandle || 'data',
-    }))
+    })),
+    settings: workflowSettings,
+  }), [nodes, edges, workflowName, workflowSettings])
+
+  const currentGraphRef = useRef(currentGraph)
+  currentGraphRef.current = currentGraph
+
+  const handleSave = useCallback(() => {
+    const graph = currentGraph()
 
 
     saveMutation.mutate({
     saveMutation.mutate({
-      name: workflowName,
-      nodes: workflowNodes,
-      connections: workflowConnections,
-      settings: workflowSettings,
+      name: graph.name,
+      nodes: graph.nodes,
+      connections: graph.connections,
+      settings: graph.settings,
       // The version this editor loaded. The server refuses the save if the
       // The version this editor loaded. The server refuses the save if the
       // stored workflow has moved on since, rather than overwriting whoever
       // stored workflow has moved on since, rather than overwriting whoever
       // got there first.
       // got there first.
       expectedVersion: loadedVersionRef.current ?? undefined,
       expectedVersion: loadedVersionRef.current ?? undefined,
     } as any)
     } as any)
-  }, [nodes, edges, saveMutation, workflowSettings, workflowName])
+  }, [currentGraph, saveMutation])
 
 
   // Handle workflow rename
   // Handle workflow rename
   const handleRename = useCallback((newName: string) => {
   const handleRename = useCallback((newName: string) => {
@@ -2708,32 +2836,102 @@ function WorkflowEditorInner() {
           Both mean the same thing to the person sitting here: what is on the
           Both mean the same thing to the person sitting here: what is on the
           canvas is no longer what is stored. */}
           canvas is no longer what is stored. */}
       {(saveConflict || savedByOther) && !isViewingExecution && (
       {(saveConflict || savedByOther) && !isViewingExecution && (
-        <div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-300 dark:border-amber-700 text-sm text-amber-900 dark:text-amber-200">
+        <div className={`px-4 py-2 border-b text-sm ${
+          autoSynced
+            ? 'bg-sky-50 dark:bg-sky-900/30 border-sky-300 dark:border-sky-700 text-sky-900 dark:text-sky-200'
+            : 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-900 dark:text-amber-200'
+        }`}>
+        <div className="flex items-center gap-3">
           <AlertTriangle className="w-4 h-4 shrink-0" />
           <AlertTriangle className="w-4 h-4 shrink-0" />
           <span className="flex-1">
           <span className="flex-1">
-            {saveConflict
-              ? `${saveConflict.username} saved this workflow while you were editing it, so your save was refused. Your changes are still here - reload to take their version, or copy what you need out first.`
-              : `${savedByOther!.username} just saved this workflow. Your copy is out of date${hasChanges ? ', and you have unsaved changes' : ''}.`}
+            {autoSynced
+              ? (savedByOther?.isSelf
+                  ? 'Brought in the changes you saved in your other tab.'
+                  : `Brought in ${savedByOther?.username ?? 'the other side'}'s changes.`) +
+                (myChanges && myChanges.length > 0
+                  ? ' Your unsaved changes here are untouched, and are now on top of theirs - save when you are ready.'
+                  : ' This canvas is up to date.')
+              : saveConflict
+              ? saveConflict.isSelf
+                ? `You saved this workflow in another tab, so this save was refused rather than undoing it. Your changes here are still on the canvas - reload to take the other tab's version, or copy what you need out first.`
+                : `${saveConflict.username} saved this workflow while you were editing it, so your save was refused. Your changes are still here - reload to take their version, or copy what you need out first.`
+              : savedByOther!.isSelf
+                ? `You just saved this workflow in another tab. This copy is out of date${hasChanges ? ', and has unsaved changes of its own' : ''}.`
+                : `${savedByOther!.username} just saved this workflow. Your copy is out of date${hasChanges ? ', and you have unsaved changes' : ''}.`}
           </span>
           </span>
-          <button
-            onClick={() => {
-              // A reload throws away whatever is on the canvas, so it is a
-              // button somebody presses, never something that happens to them
-              // mid-edit.
-              queryClient.invalidateQueries({ queryKey: ['workflow', id] })
-              window.location.reload()
-            }}
-            className="px-2 py-1 rounded bg-amber-600 text-white hover:bg-amber-700 shrink-0"
-          >
-            Reload
-          </button>
+          {!autoSynced && (
+            <button
+              onClick={() => {
+                // Only reachable if the merge could not be fetched. A reload
+                // throws away whatever is on the canvas, so it is a button
+                // somebody presses, never something that happens to them
+                // mid-edit.
+                queryClient.invalidateQueries({ queryKey: ['workflow', id] })
+                window.location.reload()
+              }}
+              className="px-2 py-1 rounded bg-amber-600 text-white hover:bg-amber-700 shrink-0"
+            >
+              Discard mine, take theirs
+            </button>
+          )}
           <button
           <button
             onClick={() => { setSaveConflict(null); dismissSavedByOther() }}
             onClick={() => { setSaveConflict(null); dismissSavedByOther() }}
-            className="px-2 py-1 rounded hover:bg-amber-100 dark:hover:bg-amber-800 shrink-0"
+            className="px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/10 shrink-0"
           >
           >
             Dismiss
             Dismiss
           </button>
           </button>
         </div>
         </div>
+
+        {/* "Your copy is out of date" asks somebody to choose between two
+            versions without showing them either. This is the difference. */}
+        {conflicts.length > 0 && (
+          <div className="mt-1 pl-7 text-amber-900 dark:text-amber-200">
+            <div className="font-medium">Both sides changed the same thing - this canvas kept its own:</div>
+            <ul className="space-y-0.5 list-disc list-inside">
+              {conflicts.map((c, i) => <li key={i}>{c}</li>)}
+            </ul>
+          </div>
+        )}
+
+        {incomingChanges !== null && (
+          <div className="mt-1 pl-7">
+            <button
+              onClick={() => setShowChanges((v) => !v)}
+              className="underline underline-offset-2 hover:no-underline"
+              disabled={incomingChanges.length === 0 && !(myChanges && myChanges.length)}
+            >
+              {autoSynced ? 'Came in' : 'Theirs'}: {summariseChanges(incomingChanges)}
+              {myChanges && myChanges.length > 0 && (
+                <> {'\u00b7'} yours, still unsaved: {summariseChanges(myChanges)}</>
+              )}
+              {(incomingChanges.length > 0 || (myChanges?.length ?? 0) > 0) &&
+                (showChanges ? ' - hide' : ' - show')}
+            </button>
+            {showChanges && (
+              <div className="mt-1 ml-1 max-h-48 overflow-y-auto space-y-2">
+                {incomingChanges.length > 0 && (
+                  <div>
+                    <div className="font-medium">
+                      {autoSynced ? 'Brought in' : 'Saved by the other side'}
+                    </div>
+                    <ul className="space-y-0.5 list-disc list-inside">
+                      {incomingChanges.map((c, i) => <li key={i}>{c.text}</li>)}
+                    </ul>
+                  </div>
+                )}
+                {myChanges && myChanges.length > 0 && (
+                  <div>
+                    <div className="font-medium">Still only on this canvas</div>
+                    <ul className="space-y-0.5 list-disc list-inside">
+                      {myChanges.map((c, i) => <li key={i}>{c.text}</li>)}
+                    </ul>
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+        )}
+        </div>
       )}
       )}
 
 
       {/* Execution Viewer Banner */}
       {/* Execution Viewer Banner */}
@@ -2773,6 +2971,7 @@ function WorkflowEditorInner() {
         onAddNode={() => setShowNodePicker(true)}
         onAddNode={() => setShowNodePicker(true)}
         onAutoLayout={autoLayout}
         onAutoLayout={autoLayout}
         otherViewers={otherViewers}
         otherViewers={otherViewers}
+        myOtherTabs={myOtherTabs}
         onUndo={() => { if (history.undo()) setHasChanges(true) }}
         onUndo={() => { if (history.undo()) setHasChanges(true) }}
         onRedo={() => { if (history.redo()) setHasChanges(true) }}
         onRedo={() => { if (history.redo()) setHasChanges(true) }}
         canUndo={history.canUndo}
         canUndo={history.canUndo}

+ 311 - 0
webui/src/utils/workflowDiff.ts

@@ -0,0 +1,311 @@
+import type { WorkflowNode, Connection } from '../api/workflows'
+import { uniqueNodeName } from './nodeNames'
+
+/**
+ * What is different between the workflow on this canvas and the one that has
+ * been stored since.
+ *
+ * Being told "your copy is out of date" and offered a reload is not much use:
+ * it asks somebody to choose between two versions without showing them either.
+ * The common case is the same person in two tabs, who has genuinely forgotten
+ * which one they changed - so the only question worth answering is "changed
+ * how?".
+ *
+ * Deliberately shallow. This lists what moved, what appeared, what was rewired
+ * and which nodes had their settings touched. It does not try to describe what
+ * changed inside a node's configuration, because "timeout 30000 became 60000"
+ * needs the node's schema to read well, and a wrong description is worse than
+ * an honest "settings changed".
+ */
+
+export type ChangeKind =
+  | 'node-added'
+  | 'node-removed'
+  | 'node-renamed'
+  | 'node-moved'
+  | 'node-config'
+  | 'node-turned-off'
+  | 'node-turned-on'
+  | 'connection-added'
+  | 'connection-removed'
+  | 'workflow-renamed'
+  | 'settings'
+
+export interface Change {
+  kind: ChangeKind
+  /** Reads as a sentence about the stored version relative to this canvas. */
+  text: string
+}
+
+export interface WorkflowGraph {
+  name?: string
+  nodes: WorkflowNode[]
+  connections: Connection[]
+  settings?: Record<string, any>
+}
+
+function nodeName(n: any, fallbackId: string): string {
+  return n?.name || n?.label || fallbackId
+}
+
+function connectionKey(c: any): string {
+  return [
+    c.sourceNodeId,
+    c.sourceOutput || 'main',
+    c.targetNodeId,
+    c.targetInput || 'data',
+  ].join(' -> ')
+}
+
+// A node dragged a couple of pixels is not a change worth reporting; a node
+// moved across the canvas is. The threshold is generous because nobody cares
+// about the difference between "moved" and "moved a bit".
+const MOVED_PX = 8
+
+export function diffWorkflows(mine: WorkflowGraph, theirs: WorkflowGraph): Change[] {
+  const changes: Change[] = []
+
+  if (mine.name && theirs.name && mine.name !== theirs.name) {
+    changes.push({ kind: 'workflow-renamed', text: `Renamed to "${theirs.name}"` })
+  }
+
+  const mineById = new Map((mine.nodes || []).map((n: any) => [n.id, n]))
+  const theirsById = new Map((theirs.nodes || []).map((n: any) => [n.id, n]))
+
+  for (const [id, node] of theirsById) {
+    if (!mineById.has(id)) {
+      changes.push({ kind: 'node-added', text: `Added "${nodeName(node, id)}"` })
+    }
+  }
+  for (const [id, node] of mineById) {
+    if (!theirsById.has(id)) {
+      changes.push({ kind: 'node-removed', text: `Deleted "${nodeName(node, id)}"` })
+    }
+  }
+
+  for (const [id, ours] of mineById) {
+    const other: any = theirsById.get(id)
+    if (!other) continue
+    const name = nodeName(other, id)
+
+    if (nodeName(ours, id) !== name) {
+      changes.push({ kind: 'node-renamed', text: `Renamed "${nodeName(ours, id)}" to "${name}"` })
+    }
+
+    const off = (n: any) => n?.disabled === true
+    if (off(ours) !== off(other)) {
+      changes.push(
+        off(other)
+          ? { kind: 'node-turned-off', text: `Turned "${name}" off` }
+          : { kind: 'node-turned-on', text: `Turned "${name}" back on` }
+      )
+    }
+
+    if (JSON.stringify(ours?.config ?? {}) !== JSON.stringify(other?.config ?? {})) {
+      changes.push({ kind: 'node-config', text: `Changed the settings of "${name}"` })
+    }
+
+    const dx = Math.abs((ours?.position?.x ?? 0) - (other?.position?.x ?? 0))
+    const dy = Math.abs((ours?.position?.y ?? 0) - (other?.position?.y ?? 0))
+    if (dx > MOVED_PX || dy > MOVED_PX) {
+      changes.push({ kind: 'node-moved', text: `Moved "${name}"` })
+    }
+  }
+
+  const mineConns = new Set((mine.connections || []).map(connectionKey))
+  const theirConns = new Set((theirs.connections || []).map(connectionKey))
+  const label = (key: string, byId: Map<string, any>) => {
+    const [source, , target] = key.split(' -> ')
+    return `${nodeName(byId.get(source), source)} to ${nodeName(byId.get(target), target)}`
+  }
+  for (const key of theirConns) {
+    if (!mineConns.has(key)) {
+      changes.push({ kind: 'connection-added', text: `Connected ${label(key, theirsById)}` })
+    }
+  }
+  for (const key of mineConns) {
+    if (!theirConns.has(key)) {
+      changes.push({ kind: 'connection-removed', text: `Disconnected ${label(key, mineById)}` })
+    }
+  }
+
+  if (JSON.stringify(mine.settings ?? {}) !== JSON.stringify(theirs.settings ?? {})) {
+    changes.push({ kind: 'settings', text: 'Changed the workflow settings' })
+  }
+
+  return changes
+}
+
+/** "2 nodes added, 1 connection removed" - a headline for the banner. */
+export function summariseChanges(changes: Change[]): string {
+  if (changes.length === 0) {
+    // Reachable: a save that only bumped the version, or a change this diff is
+    // deliberately blind to. Saying "no differences" is honest; claiming
+    // something changed would not be.
+    return 'no visible differences'
+  }
+  const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
+  const counts = {
+    added: changes.filter((c) => c.kind === 'node-added').length,
+    removed: changes.filter((c) => c.kind === 'node-removed').length,
+    changed: changes.filter((c) => ['node-config', 'node-renamed', 'node-turned-off', 'node-turned-on'].includes(c.kind)).length,
+    moved: changes.filter((c) => c.kind === 'node-moved').length,
+    wiring: changes.filter((c) => c.kind === 'connection-added' || c.kind === 'connection-removed').length,
+  }
+  const parts: string[] = []
+  if (counts.added) parts.push(`${plural(counts.added, 'node')} added`)
+  if (counts.removed) parts.push(`${plural(counts.removed, 'node')} deleted`)
+  if (counts.changed) parts.push(`${plural(counts.changed, 'node')} changed`)
+  if (counts.wiring) parts.push(`${plural(counts.wiring, 'connection')} rewired`)
+  // Moves are listed last and only when they are the whole story, because a
+  // banner that leads with "3 nodes moved" buries the node somebody deleted.
+  if (!parts.length && counts.moved) parts.push(`${plural(counts.moved, 'node')} moved`)
+  return parts.join(', ') || `${plural(changes.length, 'change')}`
+}
+
+/**
+ * Fold what has been saved elsewhere into what is on this canvas.
+ *
+ * Warning somebody that their copy is out of date and leaving them to sort it
+ * out is the weak answer: it is also what makes an accidental overwrite
+ * possible, because a canvas that never learned about the other side will
+ * happily save straight over it. Merging removes the danger at its source -
+ * once this canvas holds both sets of changes, saving it cannot lose either.
+ *
+ * Three-way, against the version this canvas started from, so "they changed
+ * it" and "I changed it" are distinguishable. Where both sides changed the
+ * same thing there is no honest automatic answer, so this canvas keeps its own
+ * and says which nodes those were. It never drops the other side silently.
+ */
+export interface MergeResult {
+  graph: WorkflowGraph
+  /** Things both sides changed, where this canvas won. Worth telling somebody. */
+  conflicts: string[]
+  /** True when the merge produced something other than the stored version. */
+  differsFromStored: boolean
+}
+
+const sameNode = (a: any, b: any): boolean => {
+  if (!a || !b) return a === b
+  return (
+    nodeName(a, a.id) === nodeName(b, b.id) &&
+    a.disabled === b.disabled &&
+    JSON.stringify(a.config ?? {}) === JSON.stringify(b.config ?? {}) &&
+    Math.round(a.position?.x ?? 0) === Math.round(b.position?.x ?? 0) &&
+    Math.round(a.position?.y ?? 0) === Math.round(b.position?.y ?? 0)
+  )
+}
+
+export function mergeWorkflows(
+  base: WorkflowGraph,
+  mine: WorkflowGraph,
+  theirs: WorkflowGraph
+): MergeResult {
+  const conflicts: string[] = []
+
+  const byId = (g: WorkflowGraph) => new Map((g.nodes || []).map((n: any) => [n.id, n]))
+  const B = byId(base)
+  const M = byId(mine)
+  const T = byId(theirs)
+
+  // Copies, because the rename pass below writes to these and the inputs are
+  // the live canvas and the fetched record.
+  const nodes: any[] = []
+  for (const id of new Set([...M.keys(), ...T.keys()])) {
+    const b = B.get(id)
+    const m: any = M.get(id)
+    const t: any = T.get(id)
+
+    if (m && t) {
+      const mineChanged = !sameNode(b, m)
+      const theirsChanged = !sameNode(b, t)
+      if (mineChanged && theirsChanged && !sameNode(m, t)) {
+        conflicts.push(`"${nodeName(m, id)}" was changed on both sides - this canvas kept its own`)
+        nodes.push({ ...m })
+      } else if (theirsChanged && !mineChanged) {
+        nodes.push({ ...t })
+      } else {
+        nodes.push({ ...m })
+      }
+      continue
+    }
+
+    if (m && !t) {
+      // Gone from the stored version. Either they deleted it, or it is new here.
+      if (b && sameNode(b, m)) continue                    // they deleted it, untouched here
+      if (b) {
+        conflicts.push(`"${nodeName(m, id)}" was deleted on the other side but changed here - kept`)
+      }
+      nodes.push({ ...m })                                 // new here, or changed here
+      continue
+    }
+
+    if (!m && t) {
+      // Missing from this canvas: either they added it, or it was deleted here.
+      if (!b) { nodes.push({ ...t }); continue }           // their new node
+      if (sameNode(b, t)) continue                         // deleted here, untouched there
+      conflicts.push(`"${nodeName(t, id)}" was deleted here but changed on the other side - kept theirs`)
+      nodes.push({ ...t })
+    }
+  }
+
+  // Each side picked its names without knowing about the other, so two tabs
+  // that both added an "HTTP Request 2" arrive with the same name on different
+  // nodes. Names are how an expression refers to a node, so a duplicate is not
+  // cosmetic - it makes {{$node["HTTP Request 2"]}} ambiguous. The stored
+  // version keeps its name and the local one is renamed, because a node that
+  // has never been saved is the one nothing can be referring to yet.
+  const storedNames = new Set((theirs.nodes || []).map((n: any) => nodeName(n, n.id)))
+  const taken = new Set<string>()
+  for (const node of nodes) {
+    const current = nodeName(node, node.id)
+    if (!taken.has(current)) { taken.add(current); continue }
+    if (storedNames.has(current) && !T.has(node.id)) {
+      const renamed = uniqueNodeName(current, taken)
+      conflicts.push(`Both sides added a node called "${current}" - this one is now "${renamed}"`)
+      node.name = renamed
+      taken.add(renamed)
+      continue
+    }
+    const renamed = uniqueNodeName(current, taken)
+    conflicts.push(`Two nodes were called "${current}" - one is now "${renamed}"`)
+    node.name = renamed
+    taken.add(renamed)
+  }
+
+  const live = new Set(nodes.map((n) => n.id))
+  const keys = (g: WorkflowGraph) => new Set((g.connections || []).map(connectionKey))
+  const baseConns = keys(base)
+  const theirConns = keys(theirs)
+  const byKey = new Map<string, any>()
+  for (const c of [...(mine.connections || []), ...(theirs.connections || [])]) {
+    byKey.set(connectionKey(c), c)
+  }
+
+  const merged = new Set(keys(mine))
+  for (const k of theirConns) if (!baseConns.has(k)) merged.add(k)     // they wired it
+  for (const k of baseConns) if (!theirConns.has(k)) merged.delete(k)  // they unwired it
+
+  const connections = [...merged]
+    .map((k) => byKey.get(k))
+    .filter((c) => c && live.has(c.sourceNodeId) && live.has(c.targetNodeId))
+
+  const pickScalar = <T,>(what: string, b: T, m: T, t: T): T => {
+    const mineChanged = JSON.stringify(b) !== JSON.stringify(m)
+    const theirsChanged = JSON.stringify(b) !== JSON.stringify(t)
+    if (mineChanged && theirsChanged && JSON.stringify(m) !== JSON.stringify(t)) {
+      conflicts.push(`${what} was changed on both sides - this canvas kept its own`)
+      return m
+    }
+    return theirsChanged && !mineChanged ? t : m
+  }
+
+  const graph: WorkflowGraph = {
+    name: pickScalar('The workflow name', base.name, mine.name, theirs.name),
+    nodes,
+    connections,
+    settings: pickScalar('The workflow settings', base.settings ?? {}, mine.settings ?? {}, theirs.settings ?? {}),
+  }
+
+  return { graph, conflicts, differsFromStored: diffWorkflows(theirs, graph).length > 0 }
+}