Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/routers/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 48 additions & 3 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,10 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
allExtensions: ReturnType<typeof buildAllWorkflowExtensions>
}) {
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<ReturnType<typeof setTimeout> | null>(null)

// Direct patch into controlled nodes state — no React Flow store dependency
const patchNode = useCallback<PatchFn>((nodeId, patch) => {
Expand All @@ -462,6 +463,48 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
}
}, [setNodes])

// ─── Tab sync ──────────────────────────────────────────────────────────────
const lastSyncedAtRef = useRef<string>(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()
Expand Down Expand Up @@ -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 (
<div className="flex flex-col flex-1 min-h-0">
Expand Down
67 changes: 55 additions & 12 deletions src/areas/workflows/WorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -750,6 +750,8 @@ function WorkflowCanvasInner({
const [pendingDropPos, setPendingDropPos] = useState<{ x: number; y: number } | null>(null)

const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const flushSaveRef = useRef<(() => void) | null>(null)
const autosaveMountedRef = useRef(false)
const preflightToastTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const didMountRef = useRef(false)

Expand All @@ -758,7 +760,8 @@ function WorkflowCanvasInner({
const historyRef = useRef<Snapshot[]>([{ 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<string>(workflow.updatedAt)

// Re-sync when workflow switches
useEffect(() => {
Expand All @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
<div className="flex flex-col flex-1 overflow-hidden">
Expand Down