fix(copilot-autolayout): more subflow cases and deal with resizing (#2236)

* fix sidebar hydration issue accurately

* improve autolayout subflow cases

* fix DOM structure to prevent HMR hydration issues
This commit is contained in:
Vikhyath Mondreti
2025-12-07 19:14:18 -08:00
committed by GitHub
parent 9f884c151c
commit 05022e3468
13 changed files with 288 additions and 82 deletions

View File

@@ -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)

View File

@@ -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>
</>

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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 (
<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>
)
}

View File

@@ -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<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
@@ -184,42 +180,37 @@ function resolveOverlaps(nodes: GraphNode[], verticalSpacing: number): void {
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)) {
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<number, GraphNode[]>,
@@ -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 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<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 = 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)
}
/**

View File

@@ -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<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)

View File

@@ -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<string, BlockState> = 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
}

View File

@@ -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<string, BlockState>,
edges: Edge[],
options: {
isContainer: boolean
layoutOptions?: {
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
alignment?: 'start' | 'center' | 'end'
}
subflowDepths?: Map<string, number>
}
) => { nodes: Map<string, GraphNode>; 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<string, BlockState>,
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<string, number>()
// 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<string, BlockState> = {}
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,
}
}
}

View File

@@ -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<SidebarState>()(
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<SidebarState>()(
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,
}),
}
)
)

View File

@@ -35,7 +35,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"jsx": "preserve",
"jsx": "react-jsx",
"plugins": [
{
"name": "next"