diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 8cf89b337..e62c245da 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -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) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 18e9d21ce..4d013ee8a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -14,12 +14,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod - -
- -
{children}
-
-
+
+ +
+ +
+ {children} +
+
diff --git a/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx b/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx index b1177e365..0b6c67610 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/layout.tsx @@ -1,3 +1,7 @@ export default function TemplatesLayout({ children }: { children: React.ReactNode }) { - return
{children}
+ return ( +
+
{children}
+
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx index 368f2a2ba..a8cbe8fe2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx @@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp export default function WorkflowLayout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 896cbffef..72f4e2b9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx index cc03c815b..7cf58d411 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index a8f360570..6e50b07de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -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' @@ -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 @@ -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 ( -
-
-
- +
+
+
+
+ +
-
+
) } diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 9ff3fb994..013198475 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -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, @@ -172,11 +169,10 @@ export function groupByLayer(nodes: Map): Map { - 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() + 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)) { + for (let i = 0; i < layerNodes.length - 1; i++) { + const node1 = layerNodes[i] + const node2 = layerNodes[i + 1] + + const node1Bottom = node1.position.y + node1.metrics.height + const requiredY = node1Bottom + verticalSpacing + + if (node2.position.y < requiredY) { hasOverlap = true + node2.position.y = requiredY - // 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 - } - } - - 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, }) } @@ -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, @@ -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() for (const layerNum of layerNumbers) { const nodesInLayer = layers.get(layerNum)! - const xPosition = padding.x + layerNum * horizontalSpacing + 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() + 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 = layerXPositions.get(layerNum)! // Calculate total height for this layer const totalHeight = nodesInLayer.reduce( @@ -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) } /** diff --git a/apps/sim/lib/workflows/autolayout/index.ts b/apps/sim/lib/workflows/autolayout/index.ts index bc8f43541..5647f3bae 100644 --- a/apps/sim/lib/workflows/autolayout/index.ts +++ b/apps/sim/lib/workflows/autolayout/index.ts @@ -1,4 +1,8 @@ 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' @@ -6,6 +10,7 @@ import { calculateSubflowDepths, filterLayoutEligibleBlockIds, getBlocksByParent, + prepareContainerDimensions, } from '@/lib/workflows/autolayout/utils' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -28,6 +33,19 @@ export function applyAutoLayout( const blocksCopy: Record = 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) diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index c0ebbe940..97cb9e071 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -12,6 +12,7 @@ import { getBlockMetrics, getBlocksByParent, isContainerType, + prepareContainerDimensions, shouldSkipAutoLayout, } from '@/lib/workflows/autolayout/utils' import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' @@ -47,6 +48,16 @@ export function applyTargetedLayout( const changedSet = new Set(changedBlockIds) const blocksCopy: Record = JSON.parse(JSON.stringify(blocks)) + // 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 groups = getBlocksByParent(blocksCopy) // Calculate subflow depths before layout to properly position blocks after subflow ends @@ -109,14 +120,15 @@ function layoutGroup( const requestedLayout = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false - // Never reposition containers, only update their dimensions - if (isContainerType(block.type)) return false + if (isContainerType(block.type)) { + return changedSet.has(id) && isDefaultPosition(block) + } return changedSet.has(id) }) const missingPositions = layoutEligibleChildIds.filter((id) => { const block = blocks[id] if (!block) return false - return !hasPosition(block) + return !hasPosition(block) || isDefaultPosition(block) }) const needsLayoutSet = new Set([...requestedLayout, ...missingPositions]) const needsLayout = Array.from(needsLayoutSet) @@ -308,3 +320,13 @@ function hasPosition(block: BlockState): boolean { const { x, y } = block.position return Number.isFinite(x) && Number.isFinite(y) } + +/** + * Checks if a block is at the default/uninitialized position (0, 0). + * New blocks typically start at this position before being laid out. + */ +function isDefaultPosition(block: BlockState): boolean { + if (!block.position) return true + const { x, y } = block.position + return x === 0 && y === 0 +} diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index a4cf5084e..7c63edc81 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -315,3 +315,126 @@ export function calculateSubflowDepths( return depths } + +/** + * Layout function type for preparing container dimensions. + * Returns laid out nodes and bounding dimensions. + */ +export type LayoutFunction = ( + blocks: Record, + edges: Edge[], + options: { + isContainer: boolean + layoutOptions?: { + horizontalSpacing?: number + verticalSpacing?: number + padding?: { x: number; y: number } + alignment?: 'start' | 'center' | 'end' + } + subflowDepths?: Map + } +) => { nodes: Map; dimensions: { width: number; height: number } } + +/** + * Pre-calculates container dimensions by laying out their children. + * Processes containers bottom-up to handle nested subflows correctly. + * This ensures accurate width/height values before root-level layout. + * + * @param blocks - All blocks in the workflow (will be mutated with updated dimensions) + * @param edges - All edges in the workflow + * @param layoutFn - The layout function to use for calculating dimensions + * @param horizontalSpacing - Horizontal spacing between blocks + * @param verticalSpacing - Vertical spacing between blocks + */ +export function prepareContainerDimensions( + blocks: Record, + edges: Edge[], + layoutFn: LayoutFunction, + horizontalSpacing: number, + verticalSpacing: number +): void { + const { children } = getBlocksByParent(blocks) + + // Build dependency graph to process nested containers bottom-up + const containerIds = Array.from(children.keys()) + const containerDepth = new Map() + + // Calculate nesting depth for each container + for (const containerId of containerIds) { + let depth = 0 + let currentId: string | undefined = containerId + while (currentId) { + const block: BlockState | undefined = blocks[currentId] + const parentId: string | undefined = block?.data?.parentId + currentId = parentId + if (currentId) depth++ + } + containerDepth.set(containerId, depth) + } + + // Sort containers by depth (deepest first) for bottom-up processing + const sortedContainerIds = containerIds.sort((a, b) => { + const depthA = containerDepth.get(a) ?? 0 + const depthB = containerDepth.get(b) ?? 0 + return depthB - depthA + }) + + // Process each container, laying out its children to determine dimensions + for (const containerId of sortedContainerIds) { + const container = blocks[containerId] + if (!container) continue + + const childIds = children.get(containerId) ?? [] + const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks) + + if (layoutChildIds.length === 0) { + // Empty container - use default dimensions + container.data = { + ...container.data, + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + container.layout = { + ...container.layout, + measuredWidth: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + measuredHeight: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + continue + } + + // Build subset of blocks and edges for this container's children + const childBlocks: Record = {} + for (const childId of layoutChildIds) { + childBlocks[childId] = blocks[childId] + } + + const childEdges = edges.filter( + (edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target) + ) + + // Layout children to get dimensions + const { dimensions } = layoutFn(childBlocks, childEdges, { + isContainer: true, + layoutOptions: { + horizontalSpacing: horizontalSpacing * 0.85, + verticalSpacing, + alignment: 'center', + }, + }) + + // Update container with calculated dimensions + const calculatedWidth = Math.max(dimensions.width, CONTAINER_DIMENSIONS.DEFAULT_WIDTH) + const calculatedHeight = Math.max(dimensions.height, CONTAINER_DIMENSIONS.DEFAULT_HEIGHT) + + container.data = { + ...container.data, + width: calculatedWidth, + height: calculatedHeight, + } + container.layout = { + ...container.layout, + measuredWidth: calculatedWidth, + measuredHeight: calculatedHeight, + } + } +} diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index 47da89629..139330eec 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -8,9 +8,11 @@ interface SidebarState { workspaceDropdownOpen: boolean sidebarWidth: number isCollapsed: boolean + _hasHydrated: boolean setWorkspaceDropdownOpen: (isOpen: boolean) => void setSidebarWidth: (width: number) => void setIsCollapsed: (isCollapsed: boolean) => void + setHasHydrated: (hasHydrated: boolean) => void } /** @@ -26,6 +28,7 @@ export const useSidebarStore = create()( workspaceDropdownOpen: false, sidebarWidth: DEFAULT_SIDEBAR_WIDTH, isCollapsed: false, + _hasHydrated: false, setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }), setSidebarWidth: (width) => { // Only enforce minimum - maximum is enforced dynamically by the resize hook @@ -47,17 +50,24 @@ export const useSidebarStore = create()( document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`) } }, + setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }), }), { name: 'sidebar-state', onRehydrateStorage: () => (state) => { - // Validate and enforce constraints after rehydration - if (state && typeof window !== 'undefined') { - // Use 0 width if collapsed (floating UI), otherwise use stored width - const width = state.isCollapsed ? 0 : state.sidebarWidth - document.documentElement.style.setProperty('--sidebar-width', `${width}px`) + // Mark store as hydrated and apply CSS variables + if (state) { + state.setHasHydrated(true) + if (typeof window !== 'undefined') { + const width = state.isCollapsed ? 0 : state.sidebarWidth + document.documentElement.style.setProperty('--sidebar-width', `${width}px`) + } } }, + partialize: (state) => ({ + sidebarWidth: state.sidebarWidth, + isCollapsed: state.isCollapsed, + }), } ) ) diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json index abaee9897..f48d70e63 100644 --- a/apps/sim/tsconfig.json +++ b/apps/sim/tsconfig.json @@ -35,7 +35,7 @@ "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, - "jsx": "preserve", + "jsx": "react-jsx", "plugins": [ { "name": "next"