diff --git a/api/routers/generation.py b/api/routers/generation.py index ad59899e..7ffcf239 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -127,6 +127,9 @@ async def _run_generation(job_id: str, image_bytes: bytes, params: dict, collect job.status = "running" def progress_cb(pct: int, step: str = "") -> None: + # Monotonic: the loading phase walks the bar up on a background thread and + # extensions then report their own 0->100 scale, so an unguarded assignment + # yanks the bar backwards on the first generation progress message. if pct > job.progress: job.progress = pct if step: diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index ffc774c7..62000c93 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -447,9 +447,10 @@ function EmbeddedCanvas({ workflow, allExtensions }: { allExtensions: ReturnType }) { const [nodes, setNodes] = useNodesState(workflow.nodes as FlowNode[]) - const [edges] = useEdgesState(workflow.edges as FlowEdge[]) + const [edges, setEdges] = useEdgesState(workflow.edges as FlowEdge[]) const { updateNodeData } = useReactFlow() const { navigate } = useNavStore() + const saveTimer = useRef | null>(null) // Direct patch into controlled nodes state — no React Flow store dependency const patchNode = useCallback((nodeId, patch) => { @@ -462,6 +463,48 @@ function EmbeddedCanvas({ workflow, allExtensions }: { } }, [setNodes]) + // ─── Tab sync ────────────────────────────────────────────────────────────── + const lastSyncedAtRef = useRef(workflow.updatedAt) + const didMountRef = useRef(false) + + // Sync local state when Workflows tab saves to the store (Workflows→Generate) + useEffect(() => { + if (workflow.updatedAt === lastSyncedAtRef.current) return + setNodes(workflow.nodes as FlowNode[]) + setEdges(workflow.edges as FlowEdge[]) + lastSyncedAtRef.current = workflow.updatedAt + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on updatedAt only; adding nodes/edges would resync on every local edit + }, [workflow.updatedAt]) + + // Persist to the store and claim the echo so the sync effect above does not + // treat our own write as an external change. The claim is optimistic — the store + // is updated before save() resolves — and is rolled back when the write fails, so + // a failed save never silently replaces the canvas with the last persisted version. + const saveAndClaim = useCallback((updated: Workflow) => { + const prevSyncedAt = lastSyncedAtRef.current + lastSyncedAtRef.current = updated.updatedAt + void useWorkflowsStore.getState().save(updated).then((res) => { + if (!res.success) lastSyncedAtRef.current = prevSyncedAt + }) + }, []) + + // Debounced save to the store when local state changes (Generate→Workflows) + // No cleanup return — lets the timer fire even if user navigates away + useEffect(() => { + if (!didMountRef.current) { didMountRef.current = true; return } + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => { + saveTimer.current = null + saveAndClaim({ + ...workflow, + nodes: nodes as WFNode[], + edges: edges as WFEdge[], + updatedAt: new Date().toISOString(), + }) + }, 500) + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounce on editable state; latest workflow read in the timeout + }, [nodes, edges]) + const currentMeshUrl = useAppStore((s) => s.currentJob?.outputUrl) const showToast = useAppStore((s) => s.showToast) const { runState, run, cancel } = useWorkflowRunStore() @@ -499,15 +542,17 @@ function EmbeddedCanvas({ workflow, allExtensions }: { return } // Persist the edited params so they survive remounts and are the values actually used. + // Drop the pending debounce — this save supersedes it. + if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null } const wf: Workflow = { ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString(), } - useWorkflowsStore.getState().save(wf) + saveAndClaim(wf) run(wf, allExtensions) - }, [firstPreflightIssue, nodes, edges, workflow, allExtensions, run, showToast]) + }, [firstPreflightIssue, nodes, edges, workflow, allExtensions, run, showToast, saveAndClaim]) return (
diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index cf5ee4bb..c9d99c1c 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -727,7 +727,7 @@ function WorkflowCanvasInner({ }: { workflow: Workflow allExtensions: WorkflowExtension[] - onSave: (w: Workflow) => void + onSave: (w: Workflow) => Promise<{ success: boolean; error?: string }> panelOpen: boolean onTogglePanel: () => void onOpen: () => void @@ -750,6 +750,8 @@ function WorkflowCanvasInner({ const [pendingDropPos, setPendingDropPos] = useState<{ x: number; y: number } | null>(null) const saveTimer = useRef | null>(null) + const flushSaveRef = useRef<(() => void) | null>(null) + const autosaveMountedRef = useRef(false) const preflightToastTimer = useRef | null>(null) const didMountRef = useRef(false) @@ -758,7 +760,8 @@ function WorkflowCanvasInner({ const historyRef = useRef([{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[] }]) const histIdxRef = useRef(0) const [histIdx, setHistIdx] = useState(0) - const skipPushRef = useRef(true) // skip the initial autosave-triggered push + const skipPushRef = useRef(true) // skip the initial autosave-triggered push + const lastSavedAtRef = useRef(workflow.updatedAt) // Re-sync when workflow switches useEffect(() => { @@ -771,19 +774,43 @@ function WorkflowCanvasInner({ // eslint-disable-next-line react-hooks/exhaustive-deps -- re-sync only when the workflow switches; adding nodes/edges would reset the editor on every change }, [workflow.id]) - // Auto-save + history push debounced + // Re-sync when Generate tab (or another external source) saves param changes + useEffect(() => { + if (workflow.updatedAt === lastSavedAtRef.current) return + setNodes(workflow.nodes as Node[]) + setEdges(workflow.edges as Edge[]) + skipPushRef.current = true + lastSavedAtRef.current = workflow.updatedAt + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on updatedAt only; adding nodes/edges would resync on every local edit + }, [workflow.updatedAt]) + + // Persist and claim the echo so the sync effect above does not treat our own + // write as an external change. The claim is optimistic — the store is updated + // before save() resolves — and is rolled back when the write fails, so a failed + // save never silently replaces the canvas with the last persisted version. + const saveAndClaim = useCallback((updated: Workflow) => { + const prevSavedAt = lastSavedAtRef.current + lastSavedAtRef.current = updated.updatedAt + void onSave(updated).then((res) => { + if (!res.success) lastSavedAtRef.current = prevSavedAt + }) + }, [onSave]) + + // Auto-save + history push debounced. useEffect(() => { if (saveTimer.current) clearTimeout(saveTimer.current) - saveTimer.current = setTimeout(() => { - const updated: Workflow = { + + const flush = (pushHistory: boolean) => { + saveTimer.current = null + flushSaveRef.current = null + saveAndClaim({ ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString(), - } - onSave(updated) + }) - if (!skipPushRef.current) { + if (pushHistory && !skipPushRef.current) { const next = historyRef.current.slice(0, histIdxRef.current + 1) next.push({ nodes, edges }) if (next.length > 50) next.shift() @@ -793,11 +820,25 @@ function WorkflowCanvasInner({ setHistIdx(newIdx) } skipPushRef.current = false - }, 500) - return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } + } + + // Only arm the unmount flush once the user has actually edited something, + // so merely opening and leaving the tab does not trigger a pointless write. + if (autosaveMountedRef.current) flushSaveRef.current = () => flush(false) + else autosaveMountedRef.current = true + saveTimer.current = setTimeout(() => flush(true), 500) + // No cleanup: the next edit clears the timer above, and the unmount effect + // below flushes a pending save so switching tabs never drops an edit. // eslint-disable-next-line react-hooks/exhaustive-deps -- debounce on editable state; latest workflow/onSave read in the timeout }, [nodes, edges]) + // Switching tabs unmounts this page — flush the pending autosave instead of + // dropping it, otherwise an edit made within the debounce window is lost. + useEffect(() => () => { + if (saveTimer.current) clearTimeout(saveTimer.current) + flushSaveRef.current?.() + }, []) + const preflightIssues = useMemo(() => { const draft: Workflow = { ...workflow, @@ -1139,10 +1180,12 @@ function WorkflowCanvasInner({ showToast(preflightIssues[0].message) return } + // Drop the pending debounce — this save supersedes it. + if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; flushSaveRef.current = null } const wf: Workflow = { ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], updatedAt: new Date().toISOString() } - onSave(wf) + saveAndClaim(wf) runWorkflow(wf, allExtensions) - }, [workflow, nodes, edges, onSave, allExtensions, isRunning, runWorkflow, cancel, preflightIssues, showToast]) + }, [workflow, nodes, edges, saveAndClaim, allExtensions, isRunning, runWorkflow, cancel, preflightIssues, showToast]) return (