From 1358ca7136db4f2605064140089147cb047a0638 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Tue, 23 Jun 2026 12:54:28 -0400 Subject: [PATCH 1/3] fix: two-way tab sync between Generate and Workflows (params persist both ways) --- api/routers/generation.py | 3 +- api/services/extension_process.py | 2 +- .../generate/components/WorkflowPanel.tsx | 32 ++++++++++++++++++- src/areas/workflows/WorkflowsPage.tsx | 23 ++++++++++--- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/api/routers/generation.py b/api/routers/generation.py index ad59899e..d64843d9 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -127,8 +127,7 @@ async def _run_generation(job_id: str, image_bytes: bytes, params: dict, collect job.status = "running" def progress_cb(pct: int, step: str = "") -> None: - if pct > job.progress: - job.progress = pct + job.progress = pct if step: job.step = step diff --git a/api/services/extension_process.py b/api/services/extension_process.py index 60ce6787..df0e8f7e 100644 --- a/api/services/extension_process.py +++ b/api/services/extension_process.py @@ -190,7 +190,7 @@ def _read_loop(self, proc: subprocess.Popen, msg_queue: queue.Queue) -> None: try: msg_queue.put(json.loads(line)) except json.JSONDecodeError: - print(f"[{self.MODEL_ID}] {line}", file=sys.stderr) + print(f"[{self.MODEL_ID}] bad JSON: {line}", file=sys.stderr) finally: msg_queue.put(None) # sentinel: process is done diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 5d6c14f3..ceabe765 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -450,6 +450,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { const [edges, setEdges, onEdgesChange] = 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) => { @@ -458,6 +459,36 @@ 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 + }, [workflow.updatedAt]) + + // 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(() => { + const now = new Date().toISOString() + const updated: Workflow = { + ...workflow, + nodes: nodes as WFNode[], + edges: edges as WFEdge[], + updatedAt: now, + } + lastSyncedAtRef.current = now + useWorkflowsStore.getState().save(updated) + }, 500) + }, [nodes, edges]) + const currentMeshUrl = useAppStore((s) => s.currentJob?.outputUrl) const showToast = useAppStore((s) => s.showToast) const { runState, run, cancel } = useWorkflowRunStore() @@ -659,7 +690,6 @@ export default function WorkflowPanel() { {workflow ? ( diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 2f5c8496..31b0a61b 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -807,7 +807,8 @@ function WorkflowCanvasInner({ const historyRef = useRef([{ nodes: workflow.nodes as Node[], edges: workflow.edges as Edge[], name: workflow.name }]) 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(() => { @@ -818,19 +819,34 @@ function WorkflowCanvasInner({ histIdxRef.current = 0 setHistIdx(0) skipPushRef.current = true + lastSavedAtRef.current = workflow.updatedAt }, [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[]) + setName(workflow.name) + skipPushRef.current = true + lastSavedAtRef.current = workflow.updatedAt + }, [workflow.updatedAt]) + + // Auto-save + history push debounced. + // No cleanup return — lets the timer fire even if the user navigates away + // before the debounce expires, keeping both tabs in sync. useEffect(() => { if (saveTimer.current) clearTimeout(saveTimer.current) saveTimer.current = setTimeout(() => { + const now = new Date().toISOString() const updated: Workflow = { ...workflow, name, nodes: nodes as WFNode[], edges: edges as WFEdge[], - updatedAt: new Date().toISOString(), + updatedAt: now, } + lastSavedAtRef.current = now onSave(updated) if (!skipPushRef.current) { @@ -844,7 +860,6 @@ function WorkflowCanvasInner({ } skipPushRef.current = false }, 500) - return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } }, [nodes, edges, name]) const preflightIssues = useMemo(() => { From f0b8554a47beff747bc96ecf5e4e7e0090ae26a9 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Tue, 18 Aug 2026 09:23:37 +0200 Subject: [PATCH 2/3] fix(workflows): repair the two-way tab sync introduced in #180 The new sync effects referenced identifiers that do not exist, so both editors crashed as soon as an external save bumped updatedAt: - WorkflowPanel: setEdges was never destructured from useEdgesState - WorkflowsPage: setName has no counterpart in WorkflowCanvasInner (renaming is handled at page level via renameTarget/handleRename) Also: - WorkflowsPage kept the debounce cleanup despite the comment claiming otherwise, so switching tabs within 500 ms dropped the edit. The pending save is now flushed on unmount instead of cancelled. - The saved-at claim is rolled back when the write fails, so a failed save no longer replaces the canvas with the last persisted version. - handleGenerate / handleRun now claim their own save and drop the pending debounce, instead of triggering a resync + redundant write that could clobber input typed during the IPC round-trip. - Restore the monotonic progress guard in generation.py: the loading phase walks the bar to 7 on a background thread and extensions then report their own 0-100 scale, so an unguarded assignment yanked the bar backwards. Co-Authored-By: Claude Opus 5 --- api/routers/generation.py | 6 +- .../generate/components/WorkflowPanel.tsx | 32 +++++++--- src/areas/workflows/WorkflowsPage.tsx | 60 ++++++++++++++----- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/api/routers/generation.py b/api/routers/generation.py index d64843d9..7ffcf239 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -127,7 +127,11 @@ async def _run_generation(job_id: str, image_bytes: bytes, params: dict, collect job.status = "running" def progress_cb(pct: int, step: str = "") -> None: - job.progress = pct + # 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: job.step = step diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index eb821092..62000c93 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -447,7 +447,7 @@ 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) @@ -473,24 +473,36 @@ function EmbeddedCanvas({ workflow, allExtensions }: { 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(() => { - const now = new Date().toISOString() - const updated: Workflow = { + saveTimer.current = null + saveAndClaim({ ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], - updatedAt: now, - } - lastSyncedAtRef.current = now - useWorkflowsStore.getState().save(updated) + 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) @@ -530,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 057790cc..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) @@ -777,28 +779,38 @@ function WorkflowCanvasInner({ if (workflow.updatedAt === lastSavedAtRef.current) return setNodes(workflow.nodes as Node[]) setEdges(workflow.edges as Edge[]) - setName(workflow.name) 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. - // No cleanup return — lets the timer fire even if the user navigates away - // before the debounce expires, keeping both tabs in sync. useEffect(() => { if (saveTimer.current) clearTimeout(saveTimer.current) - saveTimer.current = setTimeout(() => { - const now = new Date().toISOString() - const updated: Workflow = { + + const flush = (pushHistory: boolean) => { + saveTimer.current = null + flushSaveRef.current = null + saveAndClaim({ ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[], - updatedAt: now, - } - lastSavedAtRef.current = now - onSave(updated) + updatedAt: new Date().toISOString(), + }) - 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() @@ -808,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, @@ -1154,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 (
From 897c595b6d185cf5cd3ff40fde199dfc68889fe8 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Tue, 18 Aug 2026 09:46:18 +0200 Subject: [PATCH 3/3] fix(extensions): drop the "bad JSON:" prefix from the subprocess log Keeps the raw line as emitted by the extension, as before #180. Co-Authored-By: Claude Opus 5 --- api/services/extension_process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/extension_process.py b/api/services/extension_process.py index df0e8f7e..60ce6787 100644 --- a/api/services/extension_process.py +++ b/api/services/extension_process.py @@ -190,7 +190,7 @@ def _read_loop(self, proc: subprocess.Popen, msg_queue: queue.Queue) -> None: try: msg_queue.put(json.loads(line)) except json.JSONDecodeError: - print(f"[{self.MODEL_ID}] bad JSON: {line}", file=sys.stderr) + print(f"[{self.MODEL_ID}] {line}", file=sys.stderr) finally: msg_queue.put(None) # sentinel: process is done