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
18 changes: 17 additions & 1 deletion apps/sim/app/api/workflows/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{

return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
}
return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })

const emptyWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
},
variables: workflowData.variables || {},
}

return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
Expand Down
14 changes: 8 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<WorkspacePermissionsProvider>
<div className='flex min-h-screen w-full'>
<SidebarNew />
<div className='flex flex-1 flex-col'>{children}</div>
</div>
</WorkspacePermissionsProvider>
<div className='flex min-h-screen w-full'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<SidebarNew />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</Tooltip.Provider>
</GlobalCommandsProvider>
</>
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/templates/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
return (
<main className='flex flex-1 flex-col h-full overflow-hidden bg-muted/40'>
<div>{children}</div>
</main>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp

export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<main className='h-full overflow-hidden bg-muted/40'>
<main className='flex flex-1 flex-col h-full overflow-hidden bg-muted/40'>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
)
Expand Down
12 changes: 8 additions & 4 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1295,14 +1295,18 @@ const WorkflowContent = React.memo(() => {
return
}

// Check if we encountered an error loading this specific workflow to prevent infinite retries
const hasLoadError = hydration.phase === 'error' && hydration.workflowId === currentId

// Check if we need to load the workflow state:
// 1. Different workflow than currently active
// 2. Same workflow but hydration phase is not 'ready' (e.g., after a quick refresh)
const needsWorkflowLoad =
activeWorkflowId !== currentId ||
(activeWorkflowId === currentId &&
hydration.phase !== 'ready' &&
hydration.phase !== 'state-loading')
!hasLoadError &&
(activeWorkflowId !== currentId ||
(activeWorkflowId === currentId &&
hydration.phase !== 'ready' &&
hydration.phase !== 'state-loading'))

if (needsWorkflowLoad) {
const { clearDiff } = useWorkflowDiffStore.getState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ export function SidebarNew() {
// Session data
const { data: sessionData, isPending: sessionLoading } = useSession()

// Sidebar state
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
// Sidebar state - use store's hydration tracking to prevent SSR mismatch
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
const isCollapsedStore = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)

// Use default (expanded) state until hydrated to prevent hydration mismatch
const isCollapsed = hasHydrated ? isCollapsedStore : false

// Determine if we're on a workflow page (only workflow pages allow collapse and resize)
const isOnWorkflowPage = !!workflowId

Expand Down
27 changes: 10 additions & 17 deletions apps/sim/app/workspace/[workspaceId]/w/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
Expand All @@ -14,21 +14,12 @@ export default function WorkflowsPage() {
const { workflows, setActiveWorkflow } = useWorkflowRegistry()
const params = useParams()
const workspaceId = params.workspaceId as string
const [isMounted, setIsMounted] = useState(false)

// Fetch workflows using React Query
const { isLoading, isError } = useWorkflows(workspaceId)

// Track when component is mounted to avoid hydration issues
// Handle redirection once workflows are loaded
useEffect(() => {
setIsMounted(true)
}, [])

// Handle redirection once workflows are loaded and component is mounted
useEffect(() => {
// Wait for component to be mounted to avoid hydration mismatches
if (!isMounted) return

// Only proceed if workflows are done loading
if (isLoading) return

Expand All @@ -50,17 +41,19 @@ export default function WorkflowsPage() {
const firstWorkflowId = workspaceWorkflows[0]
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
}
}, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])

// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<div className='mx-auto mb-4'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
<main className='flex flex-1 flex-col h-full overflow-hidden bg-muted/40'>
<div className='flex h-full items-center justify-center'>
<div className='text-center'>
<div className='mx-auto mb-4'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
</div>
</div>
</div>
</main>
)
}
92 changes: 51 additions & 41 deletions apps/sim/lib/workflows/autolayout/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@ import {
CONTAINER_LAYOUT_OPTIONS,
DEFAULT_LAYOUT_OPTIONS,
MAX_OVERLAP_ITERATIONS,
OVERLAP_MARGIN,
} from '@/lib/workflows/autolayout/constants'
import type { Edge, GraphNode, LayoutOptions } from '@/lib/workflows/autolayout/types'
import {
boxesOverlap,
createBoundingBox,
getBlockMetrics,
normalizePositions,
prepareBlockMetrics,
Expand Down Expand Up @@ -172,54 +169,48 @@ export function groupByLayer(nodes: Map<string, GraphNode>): Map<number, GraphNo
}

/**
* Resolves overlaps between all nodes, including across layers.
* Nodes in the same layer are shifted vertically to avoid overlap.
* Nodes in different layers that overlap are shifted down.
* Resolves vertical overlaps between nodes in the same layer.
* X overlaps are prevented by construction via cumulative width-based positioning.
*/
function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
let iteration = 0
let hasOverlap = true

while (hasOverlap && iteration < MAX_OVERLAP_ITERATIONS) {
hasOverlap = false
iteration++

// Sort nodes by layer then by Y position for consistent processing
const sortedNodes = [...nodes].sort((a, b) => {
if (a.layer !== b.layer) return a.layer - b.layer
return a.position.y - b.position.y
})
// Group nodes by layer for same-layer overlap resolution
const nodesByLayer = new Map<number, GraphNode[]>()
for (const node of nodes) {
if (!nodesByLayer.has(node.layer)) {
nodesByLayer.set(node.layer, [])
}
nodesByLayer.get(node.layer)!.push(node)
}

for (let i = 0; i < sortedNodes.length; i++) {
for (let j = i + 1; j < sortedNodes.length; j++) {
const node1 = sortedNodes[i]
const node2 = sortedNodes[j]
// Process each layer independently
for (const [layer, layerNodes] of nodesByLayer) {
if (layerNodes.length < 2) continue

const box1 = createBoundingBox(node1.position, node1.metrics)
const box2 = createBoundingBox(node2.position, node2.metrics)
// Sort by Y position for consistent processing
layerNodes.sort((a, b) => a.position.y - b.position.y)

// Check for overlap with margin
if (boxesOverlap(box1, box2, OVERLAP_MARGIN)) {
hasOverlap = true
for (let i = 0; i < layerNodes.length - 1; i++) {
const node1 = layerNodes[i]
const node2 = layerNodes[i + 1]

// If in same layer, shift vertically around midpoint
if (node1.layer === node2.layer) {
const midpoint = (node1.position.y + node2.position.y) / 2

node1.position.y = midpoint - node1.metrics.height / 2 - verticalSpacing / 2
node2.position.y = midpoint + node2.metrics.height / 2 + verticalSpacing / 2
} else {
// Different layers - shift the later one down
const requiredSpace = box1.y + box1.height + verticalSpacing
if (node2.position.y < requiredSpace) {
node2.position.y = requiredSpace
}
}
const node1Bottom = node1.position.y + node1.metrics.height
const requiredY = node1Bottom + verticalSpacing

if (node2.position.y < requiredY) {
hasOverlap = true
node2.position.y = requiredY

logger.debug('Resolved overlap between blocks', {
logger.debug('Resolved vertical overlap in layer', {
layer,
block1: node1.id,
block2: node2.id,
sameLayer: node1.layer === node2.layer,
iteration,
})
}
Expand All @@ -228,14 +219,15 @@ function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
}

if (hasOverlap) {
logger.warn('Could not fully resolve all overlaps after max iterations', {
logger.warn('Could not fully resolve all vertical overlaps after max iterations', {
iterations: MAX_OVERLAP_ITERATIONS,
})
}
}

/**
* Calculates positions for nodes organized by layer
* Calculates positions for nodes organized by layer.
* Uses cumulative width-based X positioning to properly handle containers of varying widths.
*/
export function calculatePositions(
layers: Map<number, GraphNode[]>,
Expand All @@ -248,9 +240,27 @@ export function calculatePositions(

const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b)

// Calculate max width for each layer
const layerWidths = new Map<number, number>()
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
const maxWidth = Math.max(...nodesInLayer.map((n) => n.metrics.width))
layerWidths.set(layerNum, maxWidth)
}

// Calculate cumulative X positions for each layer based on actual widths
const layerXPositions = new Map<number, number>()
let cumulativeX = padding.x

for (const layerNum of layerNumbers) {
layerXPositions.set(layerNum, cumulativeX)
cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing
}

// Position nodes using cumulative X
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
const xPosition = padding.x + layerNum * horizontalSpacing
const xPosition = layerXPositions.get(layerNum)!

// Calculate total height for this layer
const totalHeight = nodesInLayer.reduce(
Expand Down Expand Up @@ -285,8 +295,8 @@ export function calculatePositions(
}
}

// Resolve overlaps across all nodes
resolveOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
// Resolve vertical overlaps within layers (X overlaps prevented by cumulative positioning)
resolveVerticalOverlaps(Array.from(layers.values()).flat(), verticalSpacing)
}

/**
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/lib/workflows/autolayout/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { createLogger } from '@/lib/logs/console/logger'
import {
DEFAULT_HORIZONTAL_SPACING,
DEFAULT_VERTICAL_SPACING,
} from '@/lib/workflows/autolayout/constants'
import { layoutContainers } from '@/lib/workflows/autolayout/containers'
import { assignLayers, layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions, LayoutResult } from '@/lib/workflows/autolayout/types'
import {
calculateSubflowDepths,
filterLayoutEligibleBlockIds,
getBlocksByParent,
prepareContainerDimensions,
} from '@/lib/workflows/autolayout/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'

Expand All @@ -28,6 +33,19 @@ export function applyAutoLayout(

const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))

const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING

// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
blocksCopy,
edges,
layoutBlocksCore,
horizontalSpacing,
verticalSpacing
)

const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)

Expand Down
Loading