Browse Source

refactor: decluttered editor toolbar, remove Save button, add save status

Per direction from the user: primary actions stay visible, no overflow
menu, remaining controls get smaller. Concretely:

- Removed the Save button. Autosave already covers it (and now
  debounces edge-only edits correctly, see the earlier autosave
  commit), and Ctrl+S still works as a shortcut without a persistent
  button.
- Replaced it with a small, quiet save-status indicator next to the
  workflow name: "Saved" (muted), "Saving..." (blue), "Unsaved
  changes" (amber), or "Save failed" (red, with a tooltip explaining
  the edits are still on the canvas but not yet on disk). Removing the
  one visible persistence signal without replacing it would leave
  people guessing whether their work is safe, which is worse than a
  redundant button.
- Secondary controls (Undo, Redo, Version History, Auto Layout,
  Duplicate, Settings) are now icon-only with a tooltip instead of
  icon-plus-label, and every button's padding is tighter. Primary
  controls (Add Node, Publish, Executions, Activate, Execute, Results)
  keep their labels since a bare icon would be ambiguous for them.
- Every control that mutates the graph or its metadata is now disabled
  with a "Not available while viewing a read-only execution snapshot"
  tooltip while isViewingExecution is true, consistently - this
  includes Settings, which was previously the only editing-adjacent
  control not gated (it could set hasChanges from a screen telling the
  user they were read-only). Execute, the trigger dropdown, Duplicate
  and Activate/Deactivate are included too, rather than staying live
  while the rest of the bar goes read-only.
- "Clear pinned data" (merged into a small pin/x control next to
  Executions) is now visible whenever something is pinned, in every
  mode - previously it hid itself specifically while viewing, which
  was the mode where the pin was most visible and most likely to be
  the thing someone wants to clear.
fszontagh 1 month ago
parent
commit
85e81cc1c1
2 changed files with 142 additions and 93 deletions
  1. 133 91
      webui/src/components/workflow/EditorHeader.tsx
  2. 9 2
      webui/src/pages/WorkflowEditorPage.tsx

+ 133 - 91
webui/src/components/workflow/EditorHeader.tsx

@@ -1,4 +1,4 @@
-import { ArrowLeft, Save, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy, Undo2, Redo2, UploadCloud } from 'lucide-react'
+import { ArrowLeft, Play, Pause, Plus, LayoutGrid, History, Pin, X, Cog, Loader2, ChevronDown, Pencil, Copy, Undo2, Redo2, UploadCloud, Check, AlertTriangle } from 'lucide-react'
 import { useState, useRef, useEffect, useCallback } from 'react'
 import { useState, useRef, useEffect, useCallback } from 'react'
 import type { ExecutionDetail } from '../../api/workflows'
 import type { ExecutionDetail } from '../../api/workflows'
 
 
@@ -8,10 +8,12 @@ interface TriggerNode {
   type: string
   type: string
 }
 }
 
 
+export type SaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'
+
 interface EditorHeaderProps {
 interface EditorHeaderProps {
   workflowName: string
   workflowName: string
   hasChanges: boolean
   hasChanges: boolean
-  isSaving: boolean
+  saveStatus: SaveStatus
   isRunning: boolean
   isRunning: boolean
   isViewingExecution: boolean
   isViewingExecution: boolean
   executingNodeId: string | null
   executingNodeId: string | null
@@ -22,7 +24,6 @@ interface EditorHeaderProps {
   isTogglingActive: boolean
   isTogglingActive: boolean
   triggers: TriggerNode[]
   triggers: TriggerNode[]
   onNavigateBack: () => void
   onNavigateBack: () => void
-  onSave: () => void
   onToggleActive: () => void
   onToggleActive: () => void
   onExecute: () => void
   onExecute: () => void
   onExecuteTrigger: (triggerNodeId: string) => void
   onExecuteTrigger: (triggerNodeId: string) => void
@@ -48,10 +49,54 @@ interface EditorHeaderProps {
   onRename?: (newName: string) => void
   onRename?: (newName: string) => void
 }
 }
 
 
+// Small, quiet, and always accurate: this is the only signal left that work is
+// or isn't on disk once the Save button is gone. "Saved" stays low-key since
+// it's the common case; "Unsaved" and "Saving" get a touch more color since
+// they're the states worth noticing; "Save failed" is red on purpose so it is
+// never mistaken for "saved".
+function SaveIndicator({ status }: { status: SaveStatus }) {
+  switch (status) {
+    case 'saving':
+      return (
+        <span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded">
+          <Loader2 className="w-3 h-3 animate-spin" />
+          Saving...
+        </span>
+      )
+    case 'unsaved':
+      return (
+        <span
+          className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 rounded"
+          title="Autosave will save this shortly, or press Ctrl+S"
+        >
+          Unsaved changes
+        </span>
+      )
+    case 'error':
+      return (
+        <span
+          className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-2 py-0.5 rounded"
+          title="The last save attempt failed - your edits are still on the canvas, but not yet on disk"
+        >
+          <AlertTriangle className="w-3 h-3" />
+          Save failed
+        </span>
+      )
+    case 'saved':
+    default:
+      return (
+        <span className="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500" title="Everything on the canvas is saved">
+          <Check className="w-3 h-3" />
+          Saved
+        </span>
+      )
+  }
+}
+
 export function EditorHeader({
 export function EditorHeader({
   workflowName,
   workflowName,
   hasChanges,
   hasChanges,
-  isSaving,
+  saveStatus,
   isRunning,
   isRunning,
   isViewingExecution,
   isViewingExecution,
   executingNodeId,
   executingNodeId,
@@ -62,7 +107,6 @@ export function EditorHeader({
   isTogglingActive,
   isTogglingActive,
   triggers,
   triggers,
   onNavigateBack,
   onNavigateBack,
-  onSave,
   onToggleActive,
   onToggleActive,
   onExecute,
   onExecute,
   onExecuteTrigger,
   onExecuteTrigger,
@@ -146,14 +190,20 @@ export function EditorHeader({
 
 
   const hasMultipleTriggers = triggers.length > 1
   const hasMultipleTriggers = triggers.length > 1
 
 
+  // Every control reachable while looking at a read-only execution snapshot
+  // gets the same treatment: disabled, with a tooltip explaining why, rather
+  // than appearing and vanishing as the mode changes.
+  const viewingReason = 'Not available while viewing a read-only execution snapshot'
+
   return (
   return (
-    <div className="h-14 px-4 flex items-center justify-between bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
-      <div className="flex items-center gap-4">
+    <div className="h-12 px-3 flex items-center justify-between bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 text-sm">
+      <div className="flex items-center gap-3 min-w-0">
         <button
         <button
           onClick={onNavigateBack}
           onClick={onNavigateBack}
-          className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-gray-600 dark:text-gray-400"
+          className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-gray-600 dark:text-gray-400"
+          title="Back to workflows"
         >
         >
-          <ArrowLeft className="w-5 h-5" />
+          <ArrowLeft className="w-4 h-4" />
         </button>
         </button>
         {isEditingName ? (
         {isEditingName ? (
           <input
           <input
@@ -163,12 +213,12 @@ export function EditorHeader({
             onChange={(e) => setEditingName(e.target.value)}
             onChange={(e) => setEditingName(e.target.value)}
             onBlur={handleNameSubmit}
             onBlur={handleNameSubmit}
             onKeyDown={handleNameKeyDown}
             onKeyDown={handleNameKeyDown}
-            className="font-semibold text-gray-900 dark:text-gray-100 bg-transparent border-b-2 border-primary-500 outline-none px-1 min-w-[200px]"
+            className="font-semibold text-gray-900 dark:text-gray-100 bg-transparent border-b-2 border-primary-500 outline-none px-1 min-w-[160px]"
           />
           />
         ) : (
         ) : (
           <h1
           <h1
             onClick={handleNameClick}
             onClick={handleNameClick}
-            className={`font-semibold text-gray-900 dark:text-gray-100 ${onRename && !isViewingExecution ? 'cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 group flex items-center gap-1' : ''}`}
+            className={`font-semibold text-gray-900 dark:text-gray-100 truncate ${onRename && !isViewingExecution ? 'cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 group flex items-center gap-1' : ''}`}
             title={onRename && !isViewingExecution ? 'Click to rename' : undefined}
             title={onRename && !isViewingExecution ? 'Click to rename' : undefined}
           >
           >
             {workflowName}
             {workflowName}
@@ -177,11 +227,7 @@ export function EditorHeader({
             )}
             )}
           </h1>
           </h1>
         )}
         )}
-        {hasChanges && (
-          <span className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 rounded">
-            Unsaved changes (Ctrl+S)
-          </span>
-        )}
+        {!isViewingExecution && <SaveIndicator status={saveStatus} />}
         {isRunning && (
         {isRunning && (
           <span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded">
           <span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded">
             <Loader2 className="w-3 h-3 animate-spin" />
             <Loader2 className="w-3 h-3 animate-spin" />
@@ -189,11 +235,12 @@ export function EditorHeader({
           </span>
           </span>
         )}
         )}
       </div>
       </div>
-      <div className="flex items-center gap-2">
+      <div className="flex items-center gap-1">
         <button
         <button
           onClick={onAddNode}
           onClick={onAddNode}
-          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+          className="flex items-center gap-1.5 px-2 py-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
           disabled={isViewingExecution}
           disabled={isViewingExecution}
+          title={isViewingExecution ? viewingReason : 'Add a node'}
         >
         >
           <Plus className="w-4 h-4" />
           <Plus className="w-4 h-4" />
           Add Node
           Add Node
@@ -202,7 +249,7 @@ export function EditorHeader({
             single-user install never sees it. */}
             single-user install never sees it. */}
         {myOtherTabs > 0 && (
         {myOtherTabs > 0 && (
           <span
           <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"
+            className="px-1.5 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.`}
             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
             {myOtherTabs + 1} tabs
@@ -216,60 +263,90 @@ export function EditorHeader({
             {otherViewers.slice(0, 3).map((v) => (
             {otherViewers.slice(0, 3).map((v) => (
               <span
               <span
                 key={v.userId}
                 key={v.userId}
-                className="w-6 h-6 rounded-full bg-indigo-500 text-white text-xs flex items-center justify-center ring-2 ring-white dark:ring-slate-800 uppercase"
+                className="w-5 h-5 rounded-full bg-indigo-500 text-white text-xs flex items-center justify-center ring-2 ring-white dark:ring-slate-800 uppercase"
               >
               >
                 {v.username.slice(0, 1)}
                 {v.username.slice(0, 1)}
               </span>
               </span>
             ))}
             ))}
             {otherViewers.length > 3 && (
             {otherViewers.length > 3 && (
-              <span className="w-6 h-6 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center ring-2 ring-white dark:ring-slate-800">
+              <span className="w-5 h-5 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center ring-2 ring-white dark:ring-slate-800">
                 +{otherViewers.length - 3}
                 +{otherViewers.length - 3}
               </span>
               </span>
             )}
             )}
           </div>
           </div>
         )}
         )}
 
 
-        {/* Shown rather than left to the shortcut: an editor that can undo and
-            does not say so is one nobody tries it in. */}
+        {/* Secondary controls: icon-only with a tooltip, to keep the bar from
+            crowding, since these are reached for far less often than Execute
+            or Publish. */}
         <button
         <button
           onClick={onUndo}
           onClick={onUndo}
           disabled={isViewingExecution || !canUndo}
           disabled={isViewingExecution || !canUndo}
-          title="Undo the last change (Ctrl+Z)"
-          className="flex items-center gap-2 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Undo the last change (Ctrl+Z)'}
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
         >
         >
           <Undo2 className="w-4 h-4" />
           <Undo2 className="w-4 h-4" />
         </button>
         </button>
         <button
         <button
           onClick={onRedo}
           onClick={onRedo}
           disabled={isViewingExecution || !canRedo}
           disabled={isViewingExecution || !canRedo}
-          title="Redo (Ctrl+Shift+Z)"
-          className="flex items-center gap-2 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Redo (Ctrl+Shift+Z)'}
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
         >
         >
           <Redo2 className="w-4 h-4" />
           <Redo2 className="w-4 h-4" />
         </button>
         </button>
-        {/* Publishing is what decides which version triggers run, so it says
-            plainly whether the stored workflow has moved past it. */}
         <button
         <button
           onClick={onShowVersions}
           onClick={onShowVersions}
-          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
-          title="Version history"
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Version history'}
           disabled={isViewingExecution}
           disabled={isViewingExecution}
         >
         >
           <History className="w-4 h-4" />
           <History className="w-4 h-4" />
         </button>
         </button>
+        <button
+          onClick={onAutoLayout}
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Auto-arrange nodes vertically'}
+          disabled={isViewingExecution}
+        >
+          <LayoutGrid className="w-4 h-4" />
+        </button>
+        <button
+          onClick={onDuplicate}
+          disabled={isDuplicating || isViewingExecution}
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Create an inactive copy of this workflow and open it'}
+        >
+          {isDuplicating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Copy className="w-4 h-4" />}
+        </button>
+        <button
+          onClick={onShowSettings}
+          disabled={isViewingExecution}
+          className="p-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
+          title={isViewingExecution ? viewingReason : 'Workflow settings'}
+        >
+          <Cog className="w-4 h-4" />
+        </button>
+
+        <div className="w-px self-stretch my-2 bg-gray-200 dark:bg-slate-700 mx-1" />
+
+        {/* Publishing is what decides which version triggers run, so it says
+            plainly whether the stored workflow has moved past it. */}
         <button
         <button
           onClick={onPublish}
           onClick={onPublish}
           disabled={isViewingExecution || isPublishing || !hasUnpublishedChanges || hasChanges}
           disabled={isViewingExecution || isPublishing || !hasUnpublishedChanges || hasChanges}
           title={
           title={
-            hasChanges
-              ? 'Waiting for the current change to save - publishing sends what is stored, not what is on screen'
-              : !hasUnpublishedChanges
-                ? `Version ${publishedVersion} is published, and is exactly what triggers run`
-                : publishedVersion === null
-                  ? 'Nothing published yet - publish to decide what triggers run'
-                  : `Publish what is stored now. Triggers currently run version ${publishedVersion}`
+            isViewingExecution
+              ? viewingReason
+              : hasChanges
+                ? 'Waiting for the current change to save - publishing sends what is stored, not what is on screen'
+                : !hasUnpublishedChanges
+                  ? `Version ${publishedVersion} is published, and is exactly what triggers run`
+                  : publishedVersion === null
+                    ? 'Nothing published yet - publish to decide what triggers run'
+                    : `Publish what is stored now. Triggers currently run version ${publishedVersion}`
           }
           }
-          className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${
+          className={`flex items-center gap-1.5 px-2 py-1 rounded-lg ${
             hasUnpublishedChanges && !hasChanges
             hasUnpublishedChanges && !hasChanges
               ? 'bg-green-600 text-white hover:bg-green-700'
               ? 'bg-green-600 text-white hover:bg-green-700'
               : 'text-gray-500 dark:text-gray-400'
               : 'text-gray-500 dark:text-gray-400'
@@ -278,18 +355,9 @@ export function EditorHeader({
           <UploadCloud className="w-4 h-4" />
           <UploadCloud className="w-4 h-4" />
           {isPublishing ? 'Publishing...' : hasUnpublishedChanges ? 'Publish' : 'Published'}
           {isPublishing ? 'Publishing...' : hasUnpublishedChanges ? 'Publish' : 'Published'}
         </button>
         </button>
-        <button
-          onClick={onAutoLayout}
-          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
-          title="Auto-arrange nodes vertically"
-          disabled={isViewingExecution}
-        >
-          <LayoutGrid className="w-4 h-4" />
-          Auto Layout
-        </button>
         <button
         <button
           onClick={onShowExecutionList}
           onClick={onShowExecutionList}
-          className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${
+          className={`flex items-center gap-1.5 px-2 py-1 rounded-lg ${
             pinnedExecution
             pinnedExecution
               ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
               ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
               : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
               : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
@@ -298,47 +366,27 @@ export function EditorHeader({
         >
         >
           <History className="w-4 h-4" />
           <History className="w-4 h-4" />
           Executions
           Executions
-          {pinnedExecution && (
-            <span className="flex items-center gap-1 text-xs bg-green-200 dark:bg-green-800 px-1.5 py-0.5 rounded">
-              <Pin className="w-3 h-3" />
-              Pinned
-            </span>
-          )}
         </button>
         </button>
-        {pinnedExecution && !isViewingExecution && (
+        {/* Clear pinned data: reachable in every mode where there is a pin to
+            clear, viewing included - it only clears the pin store, so there is
+            nothing about Viewing mode that makes it unsafe here. */}
+        {pinnedExecution && (
           <button
           <button
             onClick={onUnpinExecution}
             onClick={onUnpinExecution}
-            className="flex items-center gap-1 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
+            className="flex items-center gap-1 px-1.5 py-1 text-xs text-green-700 dark:text-green-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
             title="Clear pinned data"
             title="Clear pinned data"
           >
           >
+            <Pin className="w-3 h-3" />
             <X className="w-3 h-3" />
             <X className="w-3 h-3" />
-            Clear
           </button>
           </button>
         )}
         )}
-        <button
-          onClick={onDuplicate}
-          disabled={isDuplicating}
-          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50"
-          title="Create an inactive copy of this workflow and open it"
-        >
-          {isDuplicating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Copy className="w-4 h-4" />}
-          Duplicate
-        </button>
-        <button
-          onClick={onShowSettings}
-          className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
-          title="Workflow Settings"
-        >
-          <Cog className="w-4 h-4" />
-          Settings
-        </button>
         <button
         <button
           onClick={onToggleActive}
           onClick={onToggleActive}
-          disabled={isTogglingActive}
-          title={isActive
+          disabled={isTogglingActive || isViewingExecution}
+          title={isViewingExecution ? viewingReason : isActive
             ? 'Stop running this workflow on its schedule'
             ? 'Stop running this workflow on its schedule'
             : 'Run this workflow on its schedule'}
             : 'Run this workflow on its schedule'}
-          className={`flex items-center gap-2 px-3 py-1.5 rounded-lg disabled:opacity-50 ${
+          className={`flex items-center gap-1.5 px-2 py-1 rounded-lg disabled:opacity-50 ${
             isActive
             isActive
               ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
               ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50'
               : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600'
               : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600'
@@ -353,22 +401,15 @@ export function EditorHeader({
           )}
           )}
           {isActive ? 'Deactivate' : 'Activate'}
           {isActive ? 'Deactivate' : 'Activate'}
         </button>
         </button>
-        <button
-          onClick={onSave}
-          disabled={!hasChanges || isSaving}
-          className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-slate-600 disabled:opacity-50"
-        >
-          <Save className="w-4 h-4" />
-          {isSaving ? 'Saving...' : 'Save'}
-        </button>
         {/* Execute button - shows dropdown if multiple triggers */}
         {/* Execute button - shows dropdown if multiple triggers */}
         <div className="relative" ref={dropdownRef}>
         <div className="relative" ref={dropdownRef}>
           {hasMultipleTriggers ? (
           {hasMultipleTriggers ? (
             <>
             <>
               <button
               <button
                 onClick={() => setShowTriggerDropdown(!showTriggerDropdown)}
                 onClick={() => setShowTriggerDropdown(!showTriggerDropdown)}
-                disabled={isRunning}
-                className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+                disabled={isRunning || isViewingExecution}
+                title={isViewingExecution ? `${viewingReason} - Execute would start a new run of the current graph, not the one shown here` : undefined}
+                className="flex items-center gap-1.5 px-2 py-1 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
               >
               >
                 <Play className="w-4 h-4" />
                 <Play className="w-4 h-4" />
                 Execute
                 Execute
@@ -401,8 +442,9 @@ export function EditorHeader({
           ) : (
           ) : (
             <button
             <button
               onClick={onExecute}
               onClick={onExecute}
-              disabled={isRunning}
-              className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+              disabled={isRunning || isViewingExecution}
+              title={isViewingExecution ? `${viewingReason} - Execute would start a new run of the current graph, not the one shown here` : undefined}
+              className="flex items-center gap-1.5 px-2 py-1 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
             >
             >
               <Play className="w-4 h-4" />
               <Play className="w-4 h-4" />
               Execute
               Execute
@@ -412,7 +454,7 @@ export function EditorHeader({
         {hasExecutionData && (
         {hasExecutionData && (
           <button
           <button
             onClick={onToggleExecutionPanel}
             onClick={onToggleExecutionPanel}
-            className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${
+            className={`flex items-center gap-1.5 px-2 py-1 rounded-lg ${
               showExecutionPanel
               showExecutionPanel
                 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
                 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
                 : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600'
                 : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600'

+ 9 - 2
webui/src/pages/WorkflowEditorPage.tsx

@@ -2852,7 +2852,15 @@ function WorkflowEditorInner() {
       <EditorHeader
       <EditorHeader
         workflowName={workflowName || 'Workflow'}
         workflowName={workflowName || 'Workflow'}
         hasChanges={hasChanges}
         hasChanges={hasChanges}
-        isSaving={saveMutation.isPending}
+        saveStatus={
+          saveMutation.isPending
+            ? 'saving'
+            : saveMutation.isError
+              ? 'error'
+              : hasChanges
+                ? 'unsaved'
+                : 'saved'
+        }
         isRunning={executionState.status === 'running'}
         isRunning={executionState.status === 'running'}
         isViewingExecution={isViewingExecution}
         isViewingExecution={isViewingExecution}
         executingNodeId={executingNodeId}
         executingNodeId={executingNodeId}
@@ -2863,7 +2871,6 @@ function WorkflowEditorInner() {
         isTogglingActive={toggleActiveMutation.isPending}
         isTogglingActive={toggleActiveMutation.isPending}
         triggers={triggerNodes}
         triggers={triggerNodes}
         onNavigateBack={() => navigate(fromGroupId ? `/workflows?group=${encodeURIComponent(fromGroupId)}` : '/workflows')}
         onNavigateBack={() => navigate(fromGroupId ? `/workflows?group=${encodeURIComponent(fromGroupId)}` : '/workflows')}
-        onSave={handleSave}
         onToggleActive={() => toggleActiveMutation.mutate(!!workflow?.active)}
         onToggleActive={() => toggleActiveMutation.mutate(!!workflow?.active)}
         onExecute={() => executeWorkflow()}
         onExecute={() => executeWorkflow()}
         onExecuteTrigger={executeTrigger}
         onExecuteTrigger={executeTrigger}