improvement: workflow, blocks, preview, avatars, output-select (#2840)

* improvement(workflow): ui/ux, refactors, optimizations

* improvement: blocks, preview, avatars

* improvement(output-select): ui

* update API endpoint picker to match output selector

* improvement: subflow ui/ux

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2026-01-15 17:42:59 -08:00
committed by GitHub
parent 81cc88b2e2
commit b813bf7f27
16 changed files with 421 additions and 617 deletions

View File

@@ -100,6 +100,7 @@ export const ActionBar = memo(
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
/**
* Get appropriate tooltip message based on disabled state
@@ -125,7 +126,7 @@ export const ActionBar = memo(
'dark:border-transparent dark:bg-[var(--surface-4)]'
)}
>
{!isNoteBlock && (
{!isNoteBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -148,7 +149,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && (
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -169,7 +170,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isNoteBlock && (
{!isNoteBlock && !isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -331,13 +331,16 @@ export function OutputSelect({
return (
<Combobox
size='sm'
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
groups={comboboxGroups}
options={[]}
multiSelect
multiSelectValues={normalizedSelectedValues}
onMultiSelectChange={onOutputSelect}
placeholder={selectedDisplayText}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{selectedDisplayText}</span>
}
disabled={disabled || workflowOutputs.length === 0}
align={align}
maxHeight={maxHeight}

View File

@@ -4,13 +4,13 @@ import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { ActionBar } from '../workflow-block/components'
import type { WorkflowBlockProps } from '../workflow-block/types'
interface NoteBlockNodeData extends WorkflowBlockProps {}

View File

@@ -3,16 +3,12 @@
import { useState } from 'react'
import { Check, Clipboard } from 'lucide-react'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Code,
Combobox,
Label,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
@@ -602,48 +598,19 @@ console.log(limits);`
<span>{copied.async ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
<Popover>
<PopoverTrigger asChild>
<div className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
>
<span className='whitespace-nowrap text-[12px]'>
{getAsyncExampleTitle()}
</span>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
maxHeight={300}
maxWidth={300}
minWidth={160}
border
>
<PopoverItem
active={asyncExampleType === 'execute'}
onClick={() => setAsyncExampleType('execute')}
>
Execute Job
</PopoverItem>
<PopoverItem
active={asyncExampleType === 'status'}
onClick={() => setAsyncExampleType('status')}
>
Check Status
</PopoverItem>
<PopoverItem
active={asyncExampleType === 'rate-limits'}
onClick={() => setAsyncExampleType('rate-limits')}
>
Rate Limits
</PopoverItem>
</PopoverContent>
</Popover>
<Combobox
size='sm'
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
options={[
{ label: 'Execute Job', value: 'execute' },
{ label: 'Check Status', value: 'status' },
{ label: 'Rate Limits', value: 'rate-limits' },
]}
value={asyncExampleType}
onChange={(value) => setAsyncExampleType(value as AsyncExampleType)}
align='end'
dropdownWidth={160}
/>
</div>
</div>
<Code.Viewer

View File

@@ -334,7 +334,6 @@ export function GeneralDeploy({
}}
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
lightweight
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (

View File

@@ -1,12 +1,12 @@
import { memo, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Button, Trash } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
/**
@@ -18,11 +18,16 @@ import { usePanelEditorStore } from '@/stores/panel'
const SubflowNodeStyles: React.FC = () => {
return (
<style jsx global>{`
/* Z-index management for subflow nodes */
/* Z-index management for subflow nodes - default behind blocks */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
/* Selected subflows appear above other subflows but below blocks (z-21) */
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected='true']) {
z-index: 10 !important;
}
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
@@ -63,8 +68,8 @@ export interface SubflowNodeData {
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(null)
const userPermissions = useUserPermissionsContext()
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
@@ -80,6 +85,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
const isPreviewSelected = data?.isPreviewSelected || false
/**
* Calculate the nesting level of this subflow node based on its parent hierarchy.
* Used to apply appropriate styling for nested containers.
@@ -125,8 +132,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
const isPreviewSelected = data?.isPreviewSelected || false
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor) or preview selected - blue ring
@@ -162,7 +167,12 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isPreviewSelected}
>
{!isPreview && (
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
)}
{/* Header Section */}
<div
className={cn(
@@ -180,18 +190,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
{blockName}
</span>
</div>
{!isPreview && (
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
collaborativeBatchRemoveBlocks([id])
}}
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{!isPreview && (

View File

@@ -1,23 +0,0 @@
import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections'
interface ConnectionsProps {
blockId: string
}
/**
* Displays incoming connections at the bottom left of the workflow block
*/
export function Connections({ blockId }: ConnectionsProps) {
const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId)
if (!hasIncomingConnections) return null
const connectionCount = incomingConnections.length
const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`
return (
<div className='pointer-events-none absolute top-full left-0 ml-[8px] flex items-center gap-[8px] pt-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
<span className='text-[12px] text-[var(--text-tertiary)]'>{connectionText}</span>
</div>
)
}

View File

@@ -1,2 +0,0 @@
export { ActionBar } from './action-bar/action-bar'
export { Connections } from './connections/connections'

View File

@@ -9,10 +9,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ActionBar,
Connections,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import {
useBlockProperties,
useChildWorkflow,
@@ -934,8 +931,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
)}
{shouldShowDefaultHandles && <Connections blockId={id} />}
{shouldShowDefaultHandles && (
<Handle
type='target'

View File

@@ -126,16 +126,7 @@ export function WorkflowControls() {
</PopoverTrigger>
<Tooltip.Content side='top'>{mode === 'hand' ? 'Mover' : 'Pointer'}</Tooltip.Content>
</Tooltip.Root>
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
<PopoverItem
onClick={() => {
setMode('cursor')
setIsCanvasModeOpen(false)
}}
>
<Cursor className='h-3 w-3' />
<span>Pointer</span>
</PopoverItem>
<PopoverContent side='top' sideOffset={8} maxWidth={100} minWidth={100}>
<PopoverItem
onClick={() => {
setMode('hand')
@@ -145,6 +136,15 @@ export function WorkflowControls() {
<Hand className='h-3 w-3' />
<span>Mover</span>
</PopoverItem>
<PopoverItem
onClick={() => {
setMode('cursor')
setIsCanvasModeOpen(false)
}}
>
<Cursor className='h-3 w-3' />
<span>Pointer</span>
</PopoverItem>
</PopoverContent>
</Popover>

View File

@@ -161,6 +161,24 @@ function calculatePasteOffset(
}
}
function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]> {
const result = new Map<string, Edge[]>()
edges.forEach((edge) => {
if (nodeIds.has(edge.source)) {
const list = result.get(edge.source) ?? []
list.push(edge)
result.set(edge.source, list)
return
}
if (nodeIds.has(edge.target)) {
const list = result.get(edge.target) ?? []
list.push(edge)
result.set(edge.target, list)
}
})
return result
}
/** Custom node types for ReactFlow. */
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
@@ -178,12 +196,16 @@ const edgeTypes: EdgeTypes = {
const defaultEdgeOptions = { type: 'custom' }
const reactFlowStyles = [
'bg-[var(--bg)]',
'[&_.react-flow__edges]:!z-0',
'[&_.react-flow__node]:!z-[21]',
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[60]',
'[&_.react-flow__pane]:!bg-transparent',
'[&_.react-flow__renderer]:!bg-transparent',
'[&_.react-flow__pane]:!bg-[var(--bg)]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
@@ -200,7 +222,6 @@ interface BlockData {
id: string
type: string
position: { x: number; y: number }
distance: number
}
/**
@@ -322,6 +343,59 @@ const WorkflowContent = React.memo(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
/** Checks if a node can be placed inside a container (loop/parallel). */
const canNodeEnterContainer = useCallback(
(node: Node): boolean => {
if (node.data?.type === 'starter') return false
if (node.type === 'subflowNode') return false
const block = blocks[node.id]
return !(block && TriggerUtils.isTriggerBlock(block))
},
[blocks]
)
/** Shifts position updates to ensure nodes stay within container bounds. */
const shiftUpdatesToContainerBounds = useCallback(
<T extends { newPosition: { x: number; y: number } }>(rawUpdates: T[]): T[] => {
if (rawUpdates.length === 0) return rawUpdates
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const shiftX = minX < targetMinX ? targetMinX - minX : 0
const shiftY = minY < targetMinY ? targetMinY - minY : 0
if (shiftX === 0 && shiftY === 0) return rawUpdates
return rawUpdates.map((u) => ({
...u,
newPosition: {
x: u.newPosition.x + shiftX,
y: u.newPosition.y + shiftY,
},
}))
},
[]
)
/** Applies highlight styling to a container node during drag operations. */
const highlightContainerNode = useCallback(
(containerId: string, containerKind: 'loop' | 'parallel') => {
clearDragHighlights()
const containerElement = document.querySelector(`[data-id="${containerId}"]`)
if (containerElement) {
containerElement.classList.add(
containerKind === 'loop' ? 'loop-node-drag-over' : 'parallel-node-drag-over'
)
document.body.style.cursor = 'copy'
}
},
[]
)
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
@@ -503,6 +577,99 @@ const WorkflowContent = React.memo(() => {
[collaborativeBatchUpdateParent]
)
/**
* Executes a batch parent update for nodes being moved into or out of containers.
* Consolidates the common logic used by onNodeDragStop and onSelectionDragStop.
*/
const executeBatchParentUpdate = useCallback(
(nodesToProcess: Node[], targetParentId: string | null, logMessage: string) => {
// Build set of node IDs for efficient lookup
const nodeIds = new Set(nodesToProcess.map((n) => n.id))
// Filter to nodes whose parent is actually changing
const nodesNeedingUpdate = nodesToProcess.filter((n) => {
const block = blocks[n.id]
if (!block) return false
const currentParent = block.data?.parentId || null
// Skip if the node's parent is also being moved (keep children with their parent)
if (currentParent && nodeIds.has(currentParent)) return false
return currentParent !== targetParentId
})
if (nodesNeedingUpdate.length === 0) return
// Filter out nodes that cannot enter containers (when target is a container)
const validNodes = targetParentId
? nodesNeedingUpdate.filter(canNodeEnterContainer)
: nodesNeedingUpdate
if (validNodes.length === 0) return
// Find boundary edges (edges that cross the container boundary)
const movingNodeIds = new Set(validNodes.map((n) => n.id))
const boundaryEdges = edgesForDisplay.filter((e) => {
const sourceInSelection = movingNodeIds.has(e.source)
const targetInSelection = movingNodeIds.has(e.target)
return sourceInSelection !== targetInSelection
})
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
// Build position updates
const rawUpdates = validNodes.map((n) => {
const edgesForThisNode = boundaryEdgesByNode.get(n.id) ?? []
const newPosition = targetParentId
? calculateRelativePosition(n.id, targetParentId, true)
: getNodeAbsolutePosition(n.id)
return {
blockId: n.id,
newParentId: targetParentId,
newPosition,
affectedEdges: edgesForThisNode,
}
})
// Shift to container bounds if moving into a container
const updates = targetParentId ? shiftUpdatesToContainerBounds(rawUpdates) : rawUpdates
collaborativeBatchUpdateParent(updates)
// Update display nodes
setDisplayNodes((nodes) =>
nodes.map((node) => {
const update = updates.find((u) => u.blockId === node.id)
if (update) {
return {
...node,
position: update.newPosition,
parentId: update.newParentId ?? undefined,
}
}
return node
})
)
// Resize container if moving into one
if (targetParentId) {
resizeLoopNodesWrapper()
}
logger.info(logMessage, {
targetParentId,
nodeCount: validNodes.length,
})
},
[
blocks,
edgesForDisplay,
canNodeEnterContainer,
calculateRelativePosition,
getNodeAbsolutePosition,
shiftUpdatesToContainerBounds,
collaborativeBatchUpdateParent,
resizeLoopNodesWrapper,
]
)
const addBlock = useCallback(
(
id: string,
@@ -515,6 +682,9 @@ const WorkflowContent = React.memo(() => {
autoConnectEdge?: Edge,
triggerMode?: boolean
) => {
pendingSelectionRef.current = new Set([id])
setSelectedEdges(new Map())
const blockData: Record<string, unknown> = { ...(data || {}) }
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
@@ -533,7 +703,7 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks]
[collaborativeBatchAddBlocks, setSelectedEdges]
)
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
@@ -674,104 +844,52 @@ const WorkflowContent = React.memo(() => {
copyBlocks(blockIds)
}, [contextMenuBlocks, copyBlocks])
/**
* Executes a paste operation with validation and selection handling.
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
*/
const executePasteOperation = useCallback(
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
const pasteData = preparePasteData(pasteOffset)
if (!pasteData) return
const pastedBlocksArray = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
},
[preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks]
)
const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
hasClipboard,
clipboard,
screenToFlowPosition,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
const handleContextDuplicate = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
copyBlocks(blockIds)
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
contextMenuBlocks,
copyBlocks,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
copyBlocks(contextMenuBlocks.map((b) => b.id))
executePasteOperation('duplicate', DEFAULT_PASTE_OFFSET)
}, [contextMenuBlocks, copyBlocks, executePasteOperation])
const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
@@ -880,36 +998,7 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
if (pasteData) {
const pastedBlocks = Object.values(pasteData.blocks)
const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste')
if (!validation.isValid) {
addNotification({
level: 'error',
message: validation.message!,
workflowId: activeWorkflowId || undefined,
})
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocks.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocks,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}
}
}
@@ -926,15 +1015,11 @@ const WorkflowContent = React.memo(() => {
redo,
getNodes,
copyBlocks,
preparePasteData,
collaborativeBatchAddBlocks,
hasClipboard,
effectivePermissions.canEdit,
blocks,
addNotification,
activeWorkflowId,
clipboard,
screenToFlowPosition,
executePasteOperation,
])
/**
@@ -962,34 +1047,42 @@ const WorkflowContent = React.memo(() => {
const containerAtPoint = isPointInLoopNode(newNodePosition)
const nodeIndex = new Map(getNodes().map((n) => [n.id, n]))
const candidates = Object.entries(blocks)
.filter(([id, block]) => {
if (!block.enabled) return false
if (block.type === 'response') return false
const node = nodeIndex.get(id)
if (!node) return false
const closest = Object.entries(blocks).reduce<{
id: string
type: string
position: { x: number; y: number }
distanceSquared: number
} | null>((acc, [id, block]) => {
if (!block.enabled) return acc
if (block.type === 'response') return acc
const node = nodeIndex.get(id)
if (!node) return acc
const blockParentId = blocks[id]?.data?.parentId
const dropParentId = containerAtPoint?.loopId
if (dropParentId !== blockParentId) return false
const blockParentId = blocks[id]?.data?.parentId
const dropParentId = containerAtPoint?.loopId
if (dropParentId !== blockParentId) return acc
return true
})
.map(([id, block]) => {
const anchor = getNodeAnchorPosition(id)
const distance = Math.sqrt(
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
)
const anchor = getNodeAnchorPosition(id)
const distanceSquared =
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
if (!acc || distanceSquared < acc.distanceSquared) {
return {
id,
type: block.type,
position: anchor,
distance,
distanceSquared,
}
})
.sort((a, b) => a.distance - b.distance)
}
return acc
}, null)
return candidates[0] || null
if (!closest) return null
return {
id: closest.id,
type: closest.type,
position: closest.position,
}
},
[blocks, getNodes, getNodeAnchorPosition, isPointInLoopNode]
)
@@ -1053,15 +1146,28 @@ const WorkflowContent = React.memo(() => {
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
targetPosition: { x: number; y: number }
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
return candidateBlocks
.filter((b) => b.type !== 'response')
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
const closest = candidateBlocks.reduce<{
id: string
type: string
position: { x: number; y: number }
distanceSquared: number
} | null>((acc, block) => {
if (block.type === 'response') return acc
const distanceSquared =
(block.position.x - targetPosition.x) ** 2 + (block.position.y - targetPosition.y) ** 2
if (!acc || distanceSquared < acc.distanceSquared) {
return { ...block, distanceSquared }
}
return acc
}, null)
return closest
? {
id: closest.id,
type: closest.type,
position: closest.position,
}
: undefined
},
[]
)
@@ -1637,39 +1743,27 @@ const WorkflowContent = React.memo(() => {
// Check if hovering over a container node
const containerInfo = isPointInLoopNode(position)
// Clear any previous highlighting
clearDragHighlights()
// Highlight container if hovering over it and not dragging a subflow
// Subflow drag is marked by body class flag set by toolbar
const isSubflowDrag = document.body.classList.contains('sim-drag-subflow')
if (containerInfo && !isSubflowDrag) {
const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`)
if (containerElement) {
// Determine the type of container node for appropriate styling
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as SubflowNodeData)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (containerNode?.type === 'subflowNode') {
const kind = (containerNode.data as SubflowNodeData)?.kind
if (kind === 'loop' || kind === 'parallel') {
highlightContainerNode(containerInfo.loopId, kind)
}
document.body.style.cursor = 'copy'
}
} else {
clearDragHighlights()
document.body.style.cursor = ''
}
} catch (err) {
logger.error('Error in onDragOver', { err })
}
},
[screenToFlowPosition, isPointInLoopNode, getNodes]
[screenToFlowPosition, isPointInLoopNode, getNodes, highlightContainerNode]
)
const loadingWorkflowRef = useRef<string | null>(null)
@@ -1974,6 +2068,7 @@ const WorkflowContent = React.memo(() => {
const targetInSelection = movingNodeIds.has(e.target)
return sourceInSelection !== targetInSelection
})
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
// Collect absolute positions BEFORE any mutations
const absolutePositions = new Map<string, { x: number; y: number }>()
@@ -1984,9 +2079,7 @@ const WorkflowContent = React.memo(() => {
// Build batch update with all blocks and their affected edges
const updates = validBlockIds.map((blockId) => {
const absolutePosition = absolutePositions.get(blockId)!
const edgesForThisNode = boundaryEdges.filter(
(e) => e.source === blockId || e.target === blockId
)
const edgesForThisNode = boundaryEdgesByNode.get(blockId) ?? []
return {
blockId,
newParentId: null,
@@ -2443,23 +2536,9 @@ const WorkflowContent = React.memo(() => {
setPotentialParentId(bestContainerMatch.container.id)
// Add highlight class and change cursor
const containerElement = document.querySelector(
`[data-id="${bestContainerMatch.container.id}"]`
)
if (containerElement) {
// Apply appropriate class based on container type
if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind
if (kind === 'loop' || kind === 'parallel') {
highlightContainerNode(bestContainerMatch.container.id, kind)
}
} else {
// Remove highlighting if no longer over a container
@@ -2476,6 +2555,7 @@ const WorkflowContent = React.memo(() => {
getNodeAbsolutePosition,
getNodeDepth,
updateContainerDimensionsDuringDrag,
highlightContainerNode,
]
)
@@ -2529,103 +2609,12 @@ const WorkflowContent = React.memo(() => {
previousPositions: multiNodeDragStartRef.current,
})
// Process parent updates for nodes whose parent is changing
// Check each node individually - don't rely on dragStartParentId since
// multi-node selections can contain nodes from different parents
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id))
const nodesNeedingParentUpdate = selectedNodes.filter((n) => {
const block = blocks[n.id]
if (!block) return false
const currentParent = block.data?.parentId || null
// Skip if the node's parent is also being moved (keep children with their parent)
if (currentParent && selectedNodeIds.has(currentParent)) return false
// Node needs update if current parent !== target parent
return currentParent !== potentialParentId
})
if (nodesNeedingParentUpdate.length > 0) {
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
const validNodes = nodesNeedingParentUpdate.filter((n) => {
// These restrictions only apply when moving INTO a subflow
if (potentialParentId) {
if (n.data?.type === 'starter') return false
const block = blocks[n.id]
if (block && TriggerUtils.isTriggerBlock(block)) return false
if (n.type === 'subflowNode') return false
}
return true
})
if (validNodes.length > 0) {
const movingNodeIds = new Set(validNodes.map((n) => n.id))
const boundaryEdges = edgesForDisplay.filter((e) => {
const sourceInSelection = movingNodeIds.has(e.source)
const targetInSelection = movingNodeIds.has(e.target)
return sourceInSelection !== targetInSelection
})
const rawUpdates = validNodes.map((n) => {
const edgesForThisNode = boundaryEdges.filter(
(e) => e.source === n.id || e.target === n.id
)
const newPosition = potentialParentId
? calculateRelativePosition(n.id, potentialParentId, true)
: getNodeAbsolutePosition(n.id)
return {
blockId: n.id,
newParentId: potentialParentId,
newPosition,
affectedEdges: edgesForThisNode,
}
})
let updates = rawUpdates
if (potentialParentId) {
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
const targetMinY =
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const shiftX = minX < targetMinX ? targetMinX - minX : 0
const shiftY = minY < targetMinY ? targetMinY - minY : 0
updates = rawUpdates.map((u) => ({
...u,
newPosition: {
x: u.newPosition.x + shiftX,
y: u.newPosition.y + shiftY,
},
}))
}
collaborativeBatchUpdateParent(updates)
setDisplayNodes((nodes) =>
nodes.map((node) => {
const update = updates.find((u) => u.blockId === node.id)
if (update) {
return {
...node,
position: update.newPosition,
parentId: update.newParentId ?? undefined,
}
}
return node
})
)
if (potentialParentId) {
resizeLoopNodesWrapper()
}
logger.info('Batch moved nodes to new parent', {
targetParentId: potentialParentId,
nodeCount: validNodes.length,
})
}
}
// Process parent updates using shared helper
executeBatchParentUpdate(
selectedNodes,
potentialParentId,
'Batch moved nodes to new parent'
)
// Clear drag start state
setDragStartPosition(null)
@@ -2818,31 +2807,15 @@ const WorkflowContent = React.memo(() => {
edgesForDisplay,
removeEdgesForNode,
getNodeAbsolutePosition,
calculateRelativePosition,
resizeLoopNodesWrapper,
getDragStartPosition,
setDragStartPosition,
addNotification,
activeWorkflowId,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
executeBatchParentUpdate,
]
)
// // Lock selection mode when selection drag starts (captures Shift state at drag start)
// const onSelectionStart = useCallback(() => {
// if (isShiftPressed) {
// setIsSelectionDragActive(true)
// }
// }, [isShiftPressed])
// const onSelectionEnd = useCallback(() => {
// requestAnimationFrame(() => {
// setIsSelectionDragActive(false)
// setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
// })
// }, [blocks])
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
const onSelectionDragStart = useCallback(
(_event: React.MouseEvent, nodes: Node[]) => {
@@ -2883,13 +2856,7 @@ const WorkflowContent = React.memo(() => {
if (nodes.length === 0) return
// Filter out nodes that can't be placed in containers
const eligibleNodes = nodes.filter((n) => {
if (n.data?.type === 'starter') return false
if (n.type === 'subflowNode') return false
const block = blocks[n.id]
if (block && TriggerUtils.isTriggerBlock(block)) return false
return true
})
const eligibleNodes = nodes.filter(canNodeEnterContainer)
// If no eligible nodes, clear any potential parent
if (eligibleNodes.length === 0) {
@@ -2969,18 +2936,12 @@ const WorkflowContent = React.memo(() => {
const bestMatch = sortedContainers[0]
if (bestMatch.container.id !== potentialParentId) {
clearDragHighlights()
setPotentialParentId(bestMatch.container.id)
// Add highlight
const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`)
if (containerElement) {
if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') {
containerElement.classList.add('loop-node-drag-over')
} else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
const kind = (bestMatch.container.data as SubflowNodeData)?.kind
if (kind === 'loop' || kind === 'parallel') {
highlightContainerNode(bestMatch.container.id, kind)
}
}
} else if (potentialParentId) {
@@ -2989,12 +2950,13 @@ const WorkflowContent = React.memo(() => {
}
},
[
blocks,
canNodeEnterContainer,
getNodes,
potentialParentId,
getNodeAbsolutePosition,
getNodeDepth,
clearDragHighlights,
highlightContainerNode,
]
)
@@ -3009,102 +2971,8 @@ const WorkflowContent = React.memo(() => {
previousPositions: multiNodeDragStartRef.current,
})
// Process parent updates for nodes whose parent is changing
// Check each node individually - don't rely on dragStartParentId since
// multi-node selections can contain nodes from different parents
const selectedNodeIds = new Set(nodes.map((n: Node) => n.id))
const nodesNeedingParentUpdate = nodes.filter((n: Node) => {
const block = blocks[n.id]
if (!block) return false
const currentParent = block.data?.parentId || null
// Skip if the node's parent is also being moved (keep children with their parent)
if (currentParent && selectedNodeIds.has(currentParent)) return false
// Node needs update if current parent !== target parent
return currentParent !== potentialParentId
})
if (nodesNeedingParentUpdate.length > 0) {
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
const validNodes = nodesNeedingParentUpdate.filter((n: Node) => {
// These restrictions only apply when moving INTO a subflow
if (potentialParentId) {
if (n.data?.type === 'starter') return false
const block = blocks[n.id]
if (block && TriggerUtils.isTriggerBlock(block)) return false
if (n.type === 'subflowNode') return false
}
return true
})
if (validNodes.length > 0) {
const movingNodeIds = new Set(validNodes.map((n: Node) => n.id))
const boundaryEdges = edgesForDisplay.filter((e) => {
const sourceInSelection = movingNodeIds.has(e.source)
const targetInSelection = movingNodeIds.has(e.target)
return sourceInSelection !== targetInSelection
})
const rawUpdates = validNodes.map((n: Node) => {
const edgesForThisNode = boundaryEdges.filter(
(e) => e.source === n.id || e.target === n.id
)
const newPosition = potentialParentId
? calculateRelativePosition(n.id, potentialParentId, true)
: getNodeAbsolutePosition(n.id)
return {
blockId: n.id,
newParentId: potentialParentId,
newPosition,
affectedEdges: edgesForThisNode,
}
})
let updates = rawUpdates
if (potentialParentId) {
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const shiftX = minX < targetMinX ? targetMinX - minX : 0
const shiftY = minY < targetMinY ? targetMinY - minY : 0
updates = rawUpdates.map((u) => ({
...u,
newPosition: {
x: u.newPosition.x + shiftX,
y: u.newPosition.y + shiftY,
},
}))
}
collaborativeBatchUpdateParent(updates)
setDisplayNodes((nodes) =>
nodes.map((node) => {
const update = updates.find((u) => u.blockId === node.id)
if (update) {
return {
...node,
position: update.newPosition,
parentId: update.newParentId ?? undefined,
}
}
return node
})
)
if (potentialParentId) {
resizeLoopNodesWrapper()
}
logger.info('Batch moved selection to new parent', {
targetParentId: potentialParentId,
nodeCount: validNodes.length,
})
}
}
// Process parent updates using shared helper
executeBatchParentUpdate(nodes, potentialParentId, 'Batch moved selection to new parent')
// Clear drag state
setDragStartPosition(null)
@@ -3114,14 +2982,10 @@ const WorkflowContent = React.memo(() => {
[
blocks,
getNodes,
getNodeAbsolutePosition,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
calculateRelativePosition,
resizeLoopNodesWrapper,
potentialParentId,
edgesForDisplay,
clearDragHighlights,
executeBatchParentUpdate,
]
)
@@ -3130,6 +2994,23 @@ const WorkflowContent = React.memo(() => {
usePanelEditorStore.getState().clearCurrentBlock()
}, [])
/** Prevents native text selection when starting a shift-drag on the pane. */
const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => {
if (!event.shiftKey) return
const target = event.target as HTMLElement | null
if (!target) return
const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane'))
if (!isPaneTarget) return
event.preventDefault()
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
selection.removeAllRanges()
}
}, [])
/**
* Handles node click to select the node in ReactFlow.
* Parent-child conflict resolution happens automatically in onNodesChange.
@@ -3303,6 +3184,7 @@ const WorkflowContent = React.memo(() => {
onConnectEnd={effectivePermissions.canEdit ? onConnectEnd : undefined}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onMouseDown={handleCanvasMouseDown}
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
onInit={(instance) => {

View File

@@ -1156,11 +1156,6 @@ function BlockDetailsSidebarContent({
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || blockConfig.name}
</span>
{block.enabled === false && (
<Badge variant='red' size='sm'>
Disabled
</Badge>
)}
{onClose && (
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
<X className='h-[14px] w-[14px]' />

View File

@@ -181,17 +181,18 @@ interface FitViewOnChangeProps {
*/
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const hasFittedRef = useRef(false)
const lastNodeIdsRef = useRef<string | null>(null)
useEffect(() => {
if (nodeIds.length > 0 && !hasFittedRef.current) {
hasFittedRef.current = true
// Small delay to ensure nodes are rendered before fitting
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
return null

View File

@@ -342,7 +342,7 @@ export function FolderItem({
spellCheck='false'
/>
) : (
<>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
@@ -357,7 +357,7 @@ export function FolderItem({
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
</>
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { type CSSProperties, useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
@@ -19,11 +19,6 @@ const AVATAR_CONFIG = {
interface AvatarsProps {
workflowId: string
/**
* Callback fired when the presence visibility changes.
* Used by parent components to adjust layout (e.g., text truncation spacing).
*/
onPresenceChange?: (hasAvatars: boolean) => void
}
interface PresenceUser {
@@ -85,7 +80,7 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
export function Avatars({ workflowId }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
@@ -127,19 +122,12 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
return { visibleUsers: visible, overflowCount: overflow }
}, [workflowUsers, maxVisible])
useEffect(() => {
const hasAnyAvatars = visibleUsers.length > 0
if (typeof onPresenceChange === 'function') {
onPresenceChange(hasAnyAvatars)
}
}, [visibleUsers, onPresenceChange])
if (visibleUsers.length === 0) {
return null
}
return (
<div className='-space-x-1 flex items-center'>
<div className='-space-x-1 flex flex-shrink-0 items-center'>
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>

View File

@@ -64,8 +64,6 @@ export function WorkflowItem({
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
const [hasAvatars, setHasAvatars] = useState(false)
const capturedSelectionRef = useRef<{
workflowIds: string[]
workflowNames: string | string[]
@@ -319,48 +317,50 @@ export function WorkflowItem({
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
style={{ backgroundColor: workflow.color }}
/>
<div className={clsx('min-w-0 flex-1', hasAvatars && 'pr-[8px]')}>
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={clsx(
'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
active
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div
className={clsx(
'truncate font-medium',
active
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
onDoubleClick={handleDoubleClick}
>
{workflow.name}
</div>
)}
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 items-center gap-[8px]'>
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className={clsx(
'w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
active
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div
className={clsx(
'min-w-0 flex-1 truncate font-medium',
active
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
onDoubleClick={handleDoubleClick}
>
{workflow.name}
</div>
)}
{!isEditing && <Avatars workflowId={workflow.id} />}
</div>
</div>
{!isEditing && (
<>
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
<button
type='button'
onPointerDown={handleMorePointerDown}