- {ext!.params.map((param) => {
+ {ext!.params.filter(isVisible).map((param) => {
const val = (data.params[param.id] ?? param.default) as number | string
return (
diff --git a/src/areas/workflows/nodes/WaitNode.tsx b/src/areas/workflows/nodes/WaitNode.tsx
new file mode 100644
index 0000000..8e30302
--- /dev/null
+++ b/src/areas/workflows/nodes/WaitNode.tsx
@@ -0,0 +1,52 @@
+import { Handle, Position } from '@xyflow/react'
+import type { WFNodeData } from '@shared/types/electron.d'
+import { useWorkflowRunStore } from '../workflowRunStore'
+import BaseNode from './BaseNode'
+
+const HANDLE_STYLE = { background: '#71717a', width: 14, height: 14, border: '2.5px solid #18181b' }
+
+export default function WaitNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) {
+ const status = useWorkflowRunStore((s) => s.runState.status)
+ const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId)
+ const continueRun = useWorkflowRunStore((s) => s.continueRun)
+ const isPaused = status === 'paused' && activeNodeId === id
+
+ return (
+
+
+
+
+ }
+ subheader={isPaused ? (
+
+ ) : undefined}
+ handles={
+ <>
+
+
+ >
+ }
+ >
+
+
+ {isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'}
+
+
+
+ )
+}
diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts
index 3b677e4..0b955ea 100644
--- a/src/areas/workflows/workflowRunStore.ts
+++ b/src/areas/workflows/workflowRunStore.ts
@@ -8,7 +8,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface WorkflowRunState {
- status: 'idle' | 'running' | 'done' | 'error'
+ status: 'idle' | 'running' | 'paused' | 'done' | 'error'
blockIndex: number
blockTotal: number
blockProgress: number
@@ -25,6 +25,14 @@ const IDLE: WorkflowRunState = {
// Module-level refs — survive component unmounts / navigation
const _cancel = { current: false }
const _activeJobId = { current: null as string | null }
+const _resume = { current: null as (() => void) | null }
+
+function flushResume(): void {
+ const fn = _resume.current
+ if (!fn) return
+ _resume.current = null
+ fn()
+}
// ─── Topological sort ─────────────────────────────────────────────────────────
@@ -60,9 +68,10 @@ interface WorkflowRunStore {
/** nodeId → workspace URL for image outputs (populated after each run) */
nodeImageOutputs: Record
- run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise
- cancel: () => void
- reset: () => void
+ run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise
+ cancel: () => void
+ reset: () => void
+ continueRun: () => void
}
export const useWorkflowRunStore = create((set) => ({
@@ -77,7 +86,9 @@ export const useWorkflowRunStore = create((set) => ({
const appState = useAppStore.getState()
const apiUrl = appState.apiUrl
const ordered = topoSort(workflow.nodes, workflow.edges)
- const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled)
+ const execNodes = ordered.filter((n) =>
+ (n.type === 'extensionNode' || n.type === 'waitNode') && n.data.enabled,
+ )
const selectedImagePath = appState.selectedImagePath ?? ''
const selectedImageData = overrideImageData ?? appState.selectedImageData ?? undefined
@@ -108,6 +119,7 @@ export const useWorkflowRunStore = create((set) => ({
// nodeId → { filePath, text, outputType }
const nodeOutputs = new Map()
+ const outputNodeIds = new Set(ordered.filter((n) => n.type === 'outputNode').map((n) => n.id))
// Pre-populate source nodes
for (const node of ordered) {
@@ -185,6 +197,21 @@ export const useWorkflowRunStore = create((set) => ({
runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' },
}))
+ // ── Wait node → pause until continueRun(), then passthrough ───────
+ if (node.type === 'waitNode') {
+ set((s) => ({ runState: { ...s.runState, status: 'paused', blockStep: 'Paused — click Continue' } }))
+ await new Promise((resolve) => { _resume.current = resolve })
+ if (_cancel.current) { set({ runState: IDLE, activeNodeId: null }); return }
+
+ nodeOutputs.set(node.id, {
+ filePath: nodeInputPath,
+ text: nodeInputText,
+ outputType: incomingEdges[0] ? nodeOutputs.get(incomingEdges[0].source)?.outputType : undefined,
+ })
+ set((s) => ({ runState: { ...s.runState, status: 'running' } }))
+ continue
+ }
+
// ── Model extensions → HTTP API ───────────────────────────────────
// Process extensions → IPC runProcess
const isModelNode = ext?.type === 'model'
@@ -275,6 +302,20 @@ export const useWorkflowRunStore = create((set) => ({
// Store output with type for downstream routing
const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined)
nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType })
+
+ // If this node feeds an Add-to-Scene, push the mesh to currentJob
+ // immediately so the 3D viewer loads it without waiting for the rest of the run.
+ const norm = nodeInputPath?.replace(/\\/g, '/')
+ if (
+ norm?.startsWith(workspaceDir) &&
+ workflow.edges.some((e) => e.source === node.id && outputNodeIds.has(e.target))
+ ) {
+ useAppStore.getState().updateCurrentJob({
+ status: 'done',
+ progress: 100,
+ outputUrl: `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}`,
+ })
+ }
}
// ── Collect image outputs for preview nodes ───────────────────────
@@ -292,7 +333,8 @@ export const useWorkflowRunStore = create((set) => ({
let outputUrl: string | undefined
let outputPath: string | undefined
- const outputNodeDef = ordered.find((n) => n.type === 'outputNode')
+ // Use the last AddToScene in topo order — its predecessor is the final scene mesh.
+ const outputNodeDef = [...ordered].reverse().find((n) => n.type === 'outputNode')
if (outputNodeDef) {
for (const edge of workflow.edges.filter((e) => e.target === outputNodeDef.id)) {
const src = nodeOutputs.get(edge.source)
@@ -343,6 +385,7 @@ export const useWorkflowRunStore = create((set) => ({
cancel() {
_cancel.current = true
+ flushResume()
if (_activeJobId.current) {
const apiUrl = useAppStore.getState().apiUrl
axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {})
@@ -354,4 +397,8 @@ export const useWorkflowRunStore = create((set) => ({
reset() {
set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} })
},
+
+ continueRun() {
+ flushResume()
+ },
}))
diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts
index acb21ad..f5d5ca8 100644
--- a/src/shared/types/electron.d.ts
+++ b/src/shared/types/electron.d.ts
@@ -38,6 +38,7 @@ export interface ParamSchema {
max?: number
step?: number
tooltip?: string
+ show_if?: Record
}
export interface ProcessExtension {