Skip to content

Commit 2f51ecc

Browse files
andresdjassoclaude
andcommitted
fix(workflow): keep outputs on the right, focus newly created blocks
Connection anchors: an output now always leaves a card from the right. The cursor swell lets a drag start on any edge, but the left side is the input, so anchoring an outgoing edge there drew a line out of the input port and read as a second input. `normalizeCursorSourceHandleId` resolves every drag to the right anchor, `normalizePositionedSourceHandleId` collapses `source-left` alongside the legacy vertical anchors (so data from the API, an older client, or a stale save self-heals on load), and only the right-side source anchor is mounted. Drops in `onConnectEnd` are always source -> target. The branch that reversed the edge for a drag starting on an input could never run: the `target` handle is `isConnectableStart={false}` and the positioned side anchors are `isConnectable={false}`, so React Flow never reports an input as a drag origin. Removed it and its now-unused imports. A newly created block is centered once its node mounts and is measured, so a card added from a drag-release, the block menu, or the toolbar is never left off-screen or under the editor panel. The editor panel's block icon uses the same type accent as the card's badge instead of the block's legacy `bgColor`, which had left the panel on the old per-integration brand colours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 44c7cc3 commit 2f51ecc

6 files changed

Lines changed: 111 additions & 62 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4-
import { Button, cn, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn'
4+
import { Button, ChipTag, DashedDividerLine, FieldDivider, Loader, Tooltip } from '@sim/emcn'
5+
import { getWorkflowTypeAccent } from '@sim/workflow-renderer'
56
import { isEqual } from 'es-toolkit'
67
import {
78
BookOpen,
@@ -50,7 +51,6 @@ import {
5051
isBlockProtected,
5152
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
5253
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
53-
import { getTileIconColorClass } from '@/blocks/icon-color'
5454
import { getBlock } from '@/blocks/registry'
5555
import { useFolderMap } from '@/hooks/queries/folders'
5656
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
@@ -96,6 +96,7 @@ export function Editor() {
9696
const currentWorkflow = useCurrentWorkflow()
9797
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
9898
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
99+
const typeAccent = getWorkflowTypeAccent(currentBlock?.type ?? '')
99100
const title = currentBlock?.name || 'Editor'
100101
const isBlockNameSearchHighlighted =
101102
activeSearchTarget?.targetKind === 'block-name' && activeSearchTarget.blockId === currentBlockId
@@ -372,18 +373,23 @@ export function Editor() {
372373
<div className='mx-[-1px] flex flex-shrink-0 items-center justify-between rounded-none border border-[var(--border)] bg-[var(--surface-4)] px-3 py-1.5'>
373374
<div className='flex min-w-0 flex-1 items-center gap-2'>
374375
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
375-
<div
376-
className='flex size-[18px] items-center justify-center overflow-hidden rounded-sm [&_img]:size-full'
377-
style={{ background: isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor }}
376+
/*
377+
* Same accent the card's type badge uses, so the panel header and
378+
* the block on the canvas read as one object. Driving it off the
379+
* block's legacy `bgColor` instead left the panel on the old
380+
* per-integration brand colours after the cards moved to the
381+
* restrained type accents.
382+
*/
383+
<ChipTag
384+
variant={typeAccent.variant}
385+
tone={typeAccent.tone}
386+
className='size-[18px] justify-center px-0'
378387
>
379388
<IconComponent
380389
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
381-
className={cn(
382-
'size-[12px]',
383-
getTileIconColorClass(isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor)
384-
)}
390+
className='size-[12px]'
385391
/>
386-
</div>
392+
</ChipTag>
387393
)}
388394
{isRenaming ? (
389395
<input

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

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ import {
2525
} from '@sim/workflow-renderer'
2626
import {
2727
getHorizontalWorkflowHandleSide,
28-
getPositionedSourceHandleId,
2928
getPositionedTargetHandleId,
30-
isPositionedTargetHandle,
3129
normalizePositionedSourceHandleId,
3230
normalizePositionedTargetHandleId,
3331
type PositionedSourceHandleSide,
@@ -794,6 +792,14 @@ const WorkflowContent = React.memo(
794792
]
795793
)
796794

795+
/**
796+
* Block queued to be centered once its node mounts and has been measured.
797+
* Creation is asynchronous — the store write, the node mount, and the
798+
* dimension measurement each land on a later frame — so `addBlock` records
799+
* the id and the effect below performs the camera move.
800+
*/
801+
const pendingFocusBlockIdRef = useRef<string | null>(null)
802+
797803
const addBlock = useCallback(
798804
(
799805
id: string,
@@ -809,6 +815,7 @@ const WorkflowContent = React.memo(
809815
) => {
810816
setPendingSelection([id])
811817
setSelectedEdges(new Map())
818+
pendingFocusBlockIdRef.current = id
812819

813820
const blockData: Record<string, unknown> = { ...(data || {}) }
814821
if (parentId) blockData.parentId = parentId
@@ -3256,32 +3263,20 @@ const WorkflowContent = React.memo(
32563263
dropSide = getHorizontalWorkflowHandleSide(clientPos.clientX - rect.left, rect.width)
32573264
}
32583265
/*
3259-
* A drag can start on an INPUT knob (the left target). The dropped
3260-
* card is then the upstream end: reusing the origin as the edge
3261-
* source would store 'target' as a sourceHandle, and the edge
3262-
* renders off arbitrary fallback anchors and never highlights.
3266+
* Always source→target. Inputs never originate a drag: the `target`
3267+
* handle sets `isConnectableStart={false}` and the positioned side
3268+
* anchors are `isConnectable={false}`, so React Flow only ever
3269+
* reports an output handle here. Dragging over an input knob starts
3270+
* from the cursor swell's temporary source handle instead.
32633271
*/
3264-
const draggedFromInput =
3265-
source.handleId === 'target' || isPositionedTargetHandle(source.handleId)
3266-
if (draggedFromInput) {
3267-
const dropSourceHandle =
3268-
!dropSide || dropSide === 'right' ? 'source' : getPositionedSourceHandleId(dropSide)
3269-
onConnect({
3270-
source: targetNode.id,
3271-
sourceHandle: dropSourceHandle,
3272-
target: source.nodeId,
3273-
targetHandle: source.handleId ?? 'target',
3274-
})
3275-
} else {
3276-
const targetHandle =
3277-
!dropSide || dropSide === 'left' ? 'target' : getPositionedTargetHandleId(dropSide)
3278-
onConnect({
3279-
source: source.nodeId,
3280-
sourceHandle,
3281-
target: targetNode.id,
3282-
targetHandle,
3283-
})
3284-
}
3272+
const targetHandle =
3273+
!dropSide || dropSide === 'left' ? 'target' : getPositionedTargetHandleId(dropSide)
3274+
onConnect({
3275+
source: source.nodeId,
3276+
sourceHandle,
3277+
target: targetNode.id,
3278+
targetHandle,
3279+
})
32853280
} else if (!targetNode) {
32863281
// Released on empty canvas: open the command palette with the drag origin
32873282
// + drop point, so the chosen block lands here wired from this handle.
@@ -3955,6 +3950,36 @@ const WorkflowContent = React.memo(
39553950
[reactFlowInstance]
39563951
)
39573952

3953+
/**
3954+
* Centers a newly created block once its node has mounted and been
3955+
* measured. A card added from a drag-release, the block menu, or the
3956+
* toolbar can land outside the viewport (or under the editor panel), so
3957+
* the camera follows it the same way it follows a click.
3958+
*
3959+
* Waits for real dimensions: `focusBlockInView` centers on the card's
3960+
* midpoint, and an unmeasured node reports no size, which would center the
3961+
* camera on its top-left corner instead.
3962+
*/
3963+
useEffect(() => {
3964+
const pendingId = pendingFocusBlockIdRef.current
3965+
if (!pendingId) return
3966+
3967+
const node = displayNodes.find((candidate) => candidate.id === pendingId)
3968+
if (!node) return
3969+
if (
3970+
typeof node.width !== 'number' ||
3971+
typeof node.height !== 'number' ||
3972+
node.width <= 0 ||
3973+
node.height <= 0
3974+
) {
3975+
return
3976+
}
3977+
3978+
pendingFocusBlockIdRef.current = null
3979+
if (embedded) return
3980+
focusBlockInView(node)
3981+
}, [displayNodes, embedded, focusBlockInView])
3982+
39583983
/**
39593984
* Handles node click to select the node in ReactFlow.
39603985
* Uses the controlled display node state so parent-child conflicts are resolved

apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,15 @@ describe('WorkflowBlockBorder mount', () => {
9696
expect(getHorizontalWorkflowHandleSide(124.9, 250)).toBe('left')
9797
expect(getHorizontalWorkflowHandleSide(125, 250)).toBe('right')
9898
expect(getHorizontalWorkflowHandleSide(230, 250)).toBe('right')
99-
expect(normalizeCursorSourceHandleId('source-cursor-left')).toBe('source-left')
99+
/* Outputs always leave from the right, whichever edge the drag began on —
100+
anchoring one on the left would put an outgoing line on the input port. */
101+
expect(normalizeCursorSourceHandleId('source-cursor-left')).toBe('source-right')
100102
expect(normalizeCursorSourceHandleId('source-cursor-right')).toBe('source-right')
101103
expect(normalizeCursorSourceHandleId('source-cursor-top')).toBe('source-right')
102104
expect(normalizeCursorSourceHandleId('source-cursor-bottom')).toBe('source-right')
105+
/* Anything that already reached the graph collapses the same way. */
106+
expect(normalizePositionedSourceHandleId('source-left')).toBe('source-right')
107+
expect(normalizePositionedSourceHandleId('source-right')).toBe('source-right')
103108
})
104109

105110
it('uses the grabbed edge only for the transient preview direction', () => {

packages/workflow-renderer/src/workflow-block/source-handle.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ export function getCursorSourceHandlePosition(side: WorkflowCardSide): Position
2626
return Position.Right
2727
}
2828

29-
/** Converts a temporary cursor handle into its persistent side anchor. */
29+
/**
30+
* Converts a temporary cursor handle into its persistent anchor.
31+
*
32+
* Outputs always leave from the right. The swell lets a drag START on any
33+
* edge — including the left, which is the input side — but the connection it
34+
* creates is an output, so it anchors right regardless of where the gesture
35+
* began. Anchoring an output on the left would put an outgoing line on the
36+
* input port and read as a second input.
37+
*/
3038
export function normalizeCursorSourceHandleId(
3139
handleId: string | null | undefined
3240
): string | null | undefined {
@@ -37,8 +45,5 @@ export function normalizeCursorSourceHandleId(
3745
const prefix = `${CURSOR_SOURCE_HANDLE_ID}-`
3846
if (!handleId?.startsWith(prefix)) return handleId
3947

40-
const side = handleId.slice(prefix.length)
41-
if (side === 'left' || side === 'right') return getPositionedSourceHandleId(side)
42-
if (side === 'top' || side === 'bottom') return getPositionedSourceHandleId('right')
43-
return handleId
48+
return getPositionedSourceHandleId('right')
4449
}

packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,23 +1174,24 @@ export function WorkflowBlockView({
11741174
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
11751175
}}
11761176
/>
1177-
{POSITIONED_SOURCE_HANDLE_SIDES.map((side) => {
1178-
const handleId = getPositionedSourceHandleId(side)
1179-
return (
1180-
<Handle
1181-
key={handleId}
1182-
type='source'
1183-
position={getReactFlowPosition(side)}
1184-
id={handleId}
1185-
className='!pointer-events-none !z-0 !rounded-none !border-none !bg-transparent !opacity-0'
1186-
style={getCenteredSideHandleStyle(side)}
1187-
data-nodeid={id}
1188-
data-handleid={handleId}
1189-
isConnectable={false}
1190-
aria-hidden='true'
1191-
/>
1192-
)
1193-
})}
1177+
{/*
1178+
Anchor for outgoing edges created by the cursor swell. Only the
1179+
right side exists: an output always leaves from the right, so
1180+
`normalizeCursorSourceHandleId` resolves every drag here no
1181+
matter which edge it started on. Mounting a left twin would
1182+
advertise an output on the input port.
1183+
*/}
1184+
<Handle
1185+
type='source'
1186+
position={getReactFlowPosition('right')}
1187+
id={getPositionedSourceHandleId('right')}
1188+
className='!pointer-events-none !z-0 !rounded-none !border-none !bg-transparent !opacity-0'
1189+
style={getCenteredSideHandleStyle('right')}
1190+
data-nodeid={id}
1191+
data-handleid={getPositionedSourceHandleId('right')}
1192+
isConnectable={false}
1193+
aria-hidden='true'
1194+
/>
11941195
</>
11951196
)}
11961197

packages/workflow-types/src/workflow.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,18 @@ export function getPositionedSourceHandleSide(
275275
return handle === 'source-left' ? 'left' : 'right'
276276
}
277277

278-
/** Collapses legacy vertical source anchors onto the canonical right-side anchor. */
278+
/**
279+
* Collapses every non-right source anchor onto the canonical right-side anchor.
280+
*
281+
* Outputs leave a card from the right, always. Left is the input side, so an
282+
* output anchored there would draw an outgoing line out of the input port and
283+
* read as a second input. Legacy vertical anchors collapse for the same reason:
284+
* top and bottom are not connection sides.
285+
*/
279286
export function normalizePositionedSourceHandleId<T extends string | null | undefined>(
280287
handle: T
281288
): T | PositionedSourceHandleId {
282-
return handle === 'source-top' || handle === 'source-bottom'
289+
return handle === 'source-top' || handle === 'source-bottom' || handle === 'source-left'
283290
? getPositionedSourceHandleId('right')
284291
: handle
285292
}

0 commit comments

Comments
 (0)