Skip to content

Commit 20312df

Browse files
committed
improvement(copilot): state persistence, subflow recreation, dynamic handle topologies
1 parent 9229002 commit 20312df

File tree

32 files changed

+1437
-312
lines changed

32 files changed

+1437
-312
lines changed

apps/sim/app/api/workflows/[id]/state/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
1111
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
1212
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
1313
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
14+
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
1415
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
1516
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
1617

@@ -180,12 +181,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
180181
)
181182

182183
const typedBlocks = filteredBlocks as Record<string, BlockState>
184+
const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks)
185+
const validationWarnings = validatedEdges.dropped.map(
186+
({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`
187+
)
183188
const canonicalLoops = generateLoopBlocks(typedBlocks)
184189
const canonicalParallels = generateParallelBlocks(typedBlocks)
185190

186191
const workflowState = {
187192
blocks: filteredBlocks,
188-
edges: state.edges,
193+
edges: validatedEdges.valid,
189194
loops: canonicalLoops,
190195
parallels: canonicalParallels,
191196
lastSaved: state.lastSaved || Date.now(),
@@ -276,7 +281,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
276281
)
277282
}
278283

279-
return NextResponse.json({ success: true, warnings }, { status: 200 })
284+
return NextResponse.json(
285+
{ success: true, warnings: [...warnings, ...validationWarnings] },
286+
{ status: 200 }
287+
)
280288
} catch (error: any) {
281289
const elapsed = Date.now() - startTime
282290
logger.error(

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
44
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
55
import { useParams } from 'next/navigation'
66
import Editor from 'react-simple-code-editor'
7-
import { useUpdateNodeInternals } from 'reactflow'
87
import {
98
Button,
109
Code,
@@ -173,7 +172,6 @@ export function ConditionInput({
173172
const [visualLineHeights, setVisualLineHeights] = useState<{
174173
[key: string]: number[]
175174
}>({})
176-
const updateNodeInternals = useUpdateNodeInternals()
177175
const batchRemoveEdges = useWorkflowStore((state) => state.batchRemoveEdges)
178176
const edges = useWorkflowStore((state) => state.edges)
179177

@@ -352,17 +350,8 @@ export function ConditionInput({
352350
if (newValue !== prevStoreValueRef.current) {
353351
prevStoreValueRef.current = newValue
354352
setStoreValue(newValue)
355-
updateNodeInternals(blockId)
356353
}
357-
}, [
358-
conditionalBlocks,
359-
blockId,
360-
subBlockId,
361-
setStoreValue,
362-
updateNodeInternals,
363-
isReady,
364-
isPreview,
365-
])
354+
}, [conditionalBlocks, blockId, subBlockId, setStoreValue, isReady, isPreview])
366355

367356
// Cleanup when component unmounts
368357
useEffect(() => {
@@ -708,8 +697,6 @@ export function ConditionInput({
708697

709698
shouldPersistRef.current = true
710699
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
711-
712-
setTimeout(() => updateNodeInternals(blockId), 0)
713700
}
714701

715702
const moveBlock = (id: string, direction: 'up' | 'down') => {
@@ -737,8 +724,6 @@ export function ConditionInput({
737724
]
738725
shouldPersistRef.current = true
739726
setConditionalBlocks(updateBlockTitles(newBlocks))
740-
741-
setTimeout(() => updateNodeInternals(blockId), 0)
742727
}
743728

744729
// Add useEffect to handle keyboard events for both dropdowns

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,14 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
198198
</div>
199199

200200
{/*
201-
* Click-catching background — selects this subflow when the body area is clicked.
202-
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
203-
* not as DOM children of this component, so child clicks never reach this div.
201+
* Subflow body background. Uses pointer-events: none so that edges rendered
202+
* inside the subflow remain clickable. Subflow selection when clicking the
203+
* empty body area is handled by React Flow's native onNodeClick which fires
204+
* on the node wrapper element surrounding this component.
204205
*/}
205206
<div
206207
className='absolute inset-0 top-[44px] rounded-b-[8px]'
207-
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
208-
onClick={() => setCurrentBlockId(id)}
208+
style={{ pointerEvents: 'none' }}
209209
/>
210210

211211
{!isPreview && (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 8 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createMcpToolId } from '@/lib/mcp/shared'
1111
import { getProviderIdFromServiceId } from '@/lib/oauth'
1212
import type { FilterRule, SortRule } from '@/lib/table/types'
1313
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
14+
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
1415
import {
1516
buildCanonicalIndex,
1617
evaluateSubBlockCondition,
@@ -1049,6 +1050,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
10491050

10501051
const subBlockRows = subBlockRowsData.rows
10511052
const subBlockState = subBlockRowsData.stateToUse
1053+
const topologySubBlocks = data.isPreview
1054+
? (data.blockState?.subBlocks ?? {})
1055+
: (currentStoreBlock?.subBlocks ?? {})
10521056
const effectiveAdvanced = useMemo(() => {
10531057
const rawValues = Object.entries(subBlockState).reduce<Record<string, unknown>>(
10541058
(acc, [key, entry]) => {
@@ -1108,34 +1112,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
11081112
*/
11091113
const conditionRows = useMemo(() => {
11101114
if (type !== 'condition') return [] as { id: string; title: string; value: string }[]
1111-
1112-
const conditionsValue = subBlockState.conditions?.value
1113-
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
1114-
1115-
try {
1116-
if (raw) {
1117-
const parsed = JSON.parse(raw) as unknown
1118-
if (Array.isArray(parsed)) {
1119-
return parsed.map((item: unknown, index: number) => {
1120-
const conditionItem = item as { id?: string; value?: unknown }
1121-
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
1122-
return {
1123-
id: conditionItem?.id ?? `${id}-cond-${index}`,
1124-
title,
1125-
value: typeof conditionItem?.value === 'string' ? conditionItem.value : '',
1126-
}
1127-
})
1128-
}
1129-
}
1130-
} catch (error) {
1131-
logger.warn('Failed to parse condition subblock value', { error, blockId: id })
1132-
}
1133-
1134-
return [
1135-
{ id: `${id}-if`, title: 'if', value: '' },
1136-
{ id: `${id}-else`, title: 'else', value: '' },
1137-
]
1138-
}, [type, subBlockState, id])
1115+
return getConditionRows(id, topologySubBlocks.conditions?.value)
1116+
}, [type, topologySubBlocks, id])
11391117

11401118
/**
11411119
* Compute per-route rows (id/value) for router_v2 blocks so we can render
@@ -1144,31 +1122,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
11441122
*/
11451123
const routerRows = useMemo(() => {
11461124
if (type !== 'router_v2') return [] as { id: string; value: string }[]
1147-
1148-
const routesValue = subBlockState.routes?.value
1149-
const raw = typeof routesValue === 'string' ? routesValue : undefined
1150-
1151-
try {
1152-
if (raw) {
1153-
const parsed = JSON.parse(raw) as unknown
1154-
if (Array.isArray(parsed)) {
1155-
return parsed.map((item: unknown, index: number) => {
1156-
const routeItem = item as { id?: string; value?: string }
1157-
return {
1158-
// Use stable ID format that matches ConditionInput's generateStableId
1159-
id: routeItem?.id ?? `${id}-route${index + 1}`,
1160-
value: routeItem?.value ?? '',
1161-
}
1162-
})
1163-
}
1164-
}
1165-
} catch (error) {
1166-
logger.warn('Failed to parse router routes value', { error, blockId: id })
1167-
}
1168-
1169-
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
1170-
return [{ id: `${id}-route1`, value: '' }]
1171-
}, [type, subBlockState, id])
1125+
return getRouterRows(id, topologySubBlocks.routes?.value)
1126+
}, [type, topologySubBlocks, id])
11721127

11731128
/**
11741129
* Compute and publish deterministic layout metrics for workflow blocks.

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { useBlockOutputFields } from './use-block-output-fields'
66
export { useBlockVisual } from './use-block-visual'
77
export { useCanvasContextMenu } from './use-canvas-context-menu'
88
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
9+
export { useDynamicHandleRefresh } from './use-dynamic-handle-refresh'
910
export { useNodeUtilities } from './use-node-utilities'
1011
export { usePreventZoom } from './use-prevent-zoom'
1112
export { useScrollManagement } from './use-scroll-management'
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useMemo, useRef } from 'react'
2+
import { useUpdateNodeInternals } from 'reactflow'
3+
import {
4+
collectDynamicHandleTopologySignatures,
5+
getChangedDynamicHandleBlockIds,
6+
} from '@/lib/workflows/dynamic-handle-topology'
7+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
8+
9+
export function useDynamicHandleRefresh() {
10+
const updateNodeInternals = useUpdateNodeInternals()
11+
const blocks = useWorkflowStore((state) => state.blocks)
12+
const previousSignaturesRef = useRef<Map<string, string>>(new Map())
13+
14+
const signatures = useMemo(() => collectDynamicHandleTopologySignatures(blocks), [blocks])
15+
16+
useEffect(() => {
17+
const changedBlockIds = getChangedDynamicHandleBlockIds(
18+
previousSignaturesRef.current,
19+
signatures
20+
)
21+
previousSignaturesRef.current = signatures
22+
23+
if (changedBlockIds.length === 0) {
24+
return
25+
}
26+
27+
const frameId = requestAnimationFrame(() => {
28+
changedBlockIds.forEach((blockId) => updateNodeInternals(blockId))
29+
})
30+
31+
return () => cancelAnimationFrame(frameId)
32+
}, [signatures, updateNodeInternals])
33+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export async function applyAutoLayoutAndUpdateStore(
116116
lastSaved: Date.now(),
117117
}
118118

119-
useWorkflowStore.setState(newWorkflowState)
119+
useWorkflowStore.getState().replaceWorkflowState(newWorkflowState)
120120

121121
logger.info('Successfully updated workflow store with auto layout', { workflowId })
122122

@@ -168,9 +168,9 @@ export async function applyAutoLayoutAndUpdateStore(
168168
})
169169

170170
// Revert the store changes since database save failed
171-
useWorkflowStore.setState({
171+
useWorkflowStore.getState().replaceWorkflowState({
172172
...workflowStore.getWorkflowState(),
173-
blocks: blocks,
173+
blocks,
174174
lastSaved: workflowStore.lastSaved,
175175
})
176176

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './auto-layout-utils'
22
export * from './block-protection-utils'
33
export * from './block-ring-utils'
4+
export * from './node-derivation'
45
export * from './node-position-utils'
56
export * from './workflow-canvas-helpers'
67
export * from './workflow-execution-utils'
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { BlockState } from '@/stores/workflows/workflow/types'
2+
3+
export const Z_INDEX = {
4+
ROOT_BLOCK: 10,
5+
CHILD_BLOCK: 1000,
6+
} as const
7+
8+
export function computeContainerZIndex(
9+
block: Pick<BlockState, 'data'>,
10+
allBlocks: Record<string, Pick<BlockState, 'data'>>
11+
): number {
12+
let depth = 0
13+
let parentId = block.data?.parentId
14+
15+
while (parentId && depth < 100) {
16+
depth++
17+
parentId = allBlocks[parentId]?.data?.parentId
18+
}
19+
20+
return depth
21+
}
22+
23+
export function computeBlockZIndex(
24+
block: Pick<BlockState, 'type' | 'data'>,
25+
allBlocks: Record<string, Pick<BlockState, 'type' | 'data'>>
26+
): number {
27+
if (block.type === 'loop' || block.type === 'parallel') {
28+
return computeContainerZIndex(block, allBlocks)
29+
}
30+
31+
return block.data?.parentId ? Z_INDEX.CHILD_BLOCK : Z_INDEX.ROOT_BLOCK
32+
}

0 commit comments

Comments
 (0)