diff --git a/apps/sim/app/(landing)/privacy/page.tsx b/apps/sim/app/(landing)/privacy/page.tsx index 7f6a0ee7d6..a32e2b980c 100644 --- a/apps/sim/app/(landing)/privacy/page.tsx +++ b/apps/sim/app/(landing)/privacy/page.tsx @@ -767,7 +767,7 @@ export default function PrivacyPolicy() { privacy@sim.ai -
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA
  • +
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA
  • We will respond to your request within a reasonable timeframe.

    diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index b94f9a2e58..eaac62a570 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -42,6 +42,40 @@ animation: dash-animation 1.5s linear infinite !important; } +/** + * React Flow selection box styling + * Uses brand-secondary color for selection highlighting + */ +.react-flow__selection { + background: rgba(51, 180, 255, 0.08) !important; + border: 1px solid var(--brand-secondary) !important; +} + +.react-flow__nodesselection-rect, +.react-flow__nodesselection { + background: transparent !important; + border: none !important; + pointer-events: none !important; +} + +/** + * Selected node ring indicator + * Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40) + */ +.react-flow__node.selected > div > div { + position: relative; +} + +.react-flow__node.selected > div > div::after { + content: ""; + position: absolute; + inset: 0; + z-index: 40; + border-radius: 8px; + box-shadow: 0 0 0 1.75px var(--brand-secondary); + pointer-events: none; +} + /** * Color tokens - single source of truth for all colors * Light mode: Warm theme diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 5754d38b24..ac9a1c3206 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -253,7 +253,7 @@ export async function POST( userId: deployment.userId, workspaceId, isDeployed: workflowRecord?.isDeployed ?? false, - variables: workflowRecord?.variables || {}, + variables: (workflowRecord?.variables as Record) ?? undefined, } const stream = await createStreamingResponse({ diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 5e1e4e8c94..bc38d2dd56 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -10,6 +10,7 @@ import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateByIdAPI') @@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ .where(eq(workflow.id, template.workflowId)) .limit(1) - const currentState = { + const currentState: Partial = { blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, parallels: normalizedData.parallels, - variables: workflowRecord?.variables || undefined, + variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined, lastSaved: Date.now(), } diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 4ad3bda21e..59c5466871 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' -import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils' +import { + type RegenerateStateInput, + regenerateWorkflowStateIds, +} from '@/lib/workflows/persistence/utils' const logger = createLogger('TemplateUseAPI') @@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template) // When connecting to template (edit mode), keep original IDs // When using template (copy mode), regenerate all IDs to avoid conflicts + const templateState = templateData.state as RegenerateStateInput const workflowState = connectToTemplate - ? templateData.state - : regenerateWorkflowStateIds(templateData.state) + ? templateState + : regenerateWorkflowStateIds(templateState) // Step 3: Save the workflow state using the existing state endpoint (like imports do) // Ensure variables in state are remapped for the new workflow as well diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 4c3916810c..fbc12ae7ec 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -243,7 +243,7 @@ export interface WorkflowExportState { color?: string exportedAt?: string } - variables?: WorkflowVariable[] + variables?: Record } export interface WorkflowExportPayload { @@ -317,36 +317,44 @@ export interface WorkspaceImportResponse { // ============================================================================= /** - * Parse workflow variables from database JSON format to array format. - * Handles both array and Record formats. + * Parse workflow variables from database JSON format to Record format. + * Handles both legacy Array and current Record formats. */ export function parseWorkflowVariables( dbVariables: DbWorkflow['variables'] -): WorkflowVariable[] | undefined { +): Record | undefined { if (!dbVariables) return undefined try { const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables + // Handle legacy Array format by converting to Record if (Array.isArray(varsObj)) { - return varsObj.map((v) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + const result: Record = {} + for (const v of varsObj) { + result[v.id] = { + id: v.id, + name: v.name, + type: v.type, + value: v.value, + } + } + return result } + // Already Record format - normalize and return if (typeof varsObj === 'object' && varsObj !== null) { - return Object.values(varsObj).map((v: unknown) => { + const result: Record = {} + for (const [key, v] of Object.entries(varsObj)) { const variable = v as { id: string; name: string; type: VariableType; value: unknown } - return { + result[key] = { id: variable.id, name: variable.name, type: variable.type, value: variable.value, } - }) + } + return result } } catch { // pass diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index f7e105d3c9..b2485fa408 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => { update: { results: [{}] }, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - // Invalid data - missing required fields const invalidData = { variables: [{ name: 'test' }] } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index ec7d5d486f..f107f31748 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types' const logger = createLogger('WorkflowVariablesAPI') +const VariableSchema = z.object({ + id: z.string(), + workflowId: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.record(z.unknown()), + z.array(z.unknown()), + ]), +}) + const VariablesSchema = z.object({ - variables: z.array( - z.object({ - id: z.string(), - workflowId: z.string(), - name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), - value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]), - }) - ), + variables: z.record(z.string(), VariableSchema), }) export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const { variables } = VariablesSchema.parse(body) - // Format variables for storage - const variablesRecord: Record = {} - variables.forEach((variable) => { - variablesRecord[variable.id] = variable - }) - - // Replace variables completely with the incoming ones + // Variables are already in Record format - use directly // The frontend is the source of truth for what variables should exist - const updatedVariables = variablesRecord - - // Update workflow with variables await db .update(workflow) .set({ - variables: updatedVariables, + variables, updatedAt: new Date(), }) .where(eq(workflow.id, workflowId)) @@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: headers, } ) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workflow variables fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: errorMessage }, { status: 500 }) } } diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index fe26fd1558..ac09e5af9d 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -332,7 +332,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return ( + onClose: () => void + onCopy: () => void + onSearch?: () => void + wrapText?: boolean + onToggleWrap?: () => void + /** When true, only shows Copy option (for subblock values) */ + copyOnly?: boolean +} + +/** + * Context menu for execution snapshot sidebar. + * Provides copy, search, and display options. + * Uses createPortal to render outside any transformed containers (like modals). + */ +export function SnapshotContextMenu({ + isOpen, + position, + menuRef, + onClose, + onCopy, + onSearch, + wrapText, + onToggleWrap, + copyOnly = false, +}: SnapshotContextMenuProps) { + if (typeof document === 'undefined') return null + + return createPortal( + + + + { + onCopy() + onClose() + }} + > + Copy + + + {!copyOnly && onSearch && ( + <> + + { + onSearch() + onClose() + }} + > + Search + + + )} + + {!copyOnly && onToggleWrap && ( + <> + + + Wrap Text + + + )} + + , + document.body + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index bfda572622..49d0e316c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -1,12 +1,23 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' -import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' +import { createPortal } from 'react-dom' +import { + Modal, + ModalBody, + ModalContent, + ModalHeader, + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, +} from '@/components/emcn' import { redactApiKeys } from '@/lib/core/security/redaction' import { cn } from '@/lib/core/utils/cn' import { BlockDetailsSidebar, + getLeftmostBlockId, WorkflowPreview, } from '@/app/workspace/[workspaceId]/w/components/preview' import { useExecutionSnapshot } from '@/hooks/queries/logs' @@ -60,6 +71,46 @@ export function ExecutionSnapshot({ }: ExecutionSnapshotProps) { const { data, isLoading, error } = useExecutionSnapshot(executionId) const [pinnedBlockId, setPinnedBlockId] = useState(null) + const autoSelectedForExecutionRef = useRef(null) + + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) + const [contextMenuBlockId, setContextMenuBlockId] = useState(null) + const menuRef = useRef(null) + + const closeMenu = useCallback(() => { + setIsMenuOpen(false) + setContextMenuBlockId(null) + }, []) + + const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuBlockId(null) + setMenuPosition({ x: e.clientX, y: e.clientY }) + setIsMenuOpen(true) + }, []) + + const handleNodeContextMenu = useCallback( + (blockId: string, mousePosition: { x: number; y: number }) => { + setContextMenuBlockId(blockId) + setMenuPosition(mousePosition) + setIsMenuOpen(true) + }, + [] + ) + + const handleCopyExecutionId = useCallback(() => { + navigator.clipboard.writeText(executionId) + closeMenu() + }, [executionId, closeMenu]) + + const handleOpenDetails = useCallback(() => { + if (contextMenuBlockId) { + setPinnedBlockId(contextMenuBlockId) + } + closeMenu() + }, [contextMenuBlockId, closeMenu]) const blockExecutions = useMemo(() => { if (!traceSpans || !Array.isArray(traceSpans)) return {} @@ -97,12 +148,21 @@ export function ExecutionSnapshot({ return blockExecutionMap }, [traceSpans]) - useEffect(() => { - setPinnedBlockId(null) - }, [executionId]) - const workflowState = data?.workflowState as WorkflowState | undefined + // Auto-select the leftmost block once when data loads for a new executionId + useEffect(() => { + if ( + workflowState && + !isMigratedWorkflowState(workflowState) && + autoSelectedForExecutionRef.current !== executionId + ) { + autoSelectedForExecutionRef.current = executionId + const leftmostId = getLeftmostBlockId(workflowState) + setPinnedBlockId(leftmostId) + } + }, [executionId, workflowState]) + const renderContent = () => { if (isLoading) { return ( @@ -169,22 +229,26 @@ export function ExecutionSnapshot({
    -
    +
    { - setPinnedBlockId((prev) => (prev === blockId ? null : blockId)) + setPinnedBlockId(blockId) }} + onNodeContextMenu={handleNodeContextMenu} + onPaneClick={() => setPinnedBlockId(null)} cursorStyle='pointer' executedBlocks={blockExecutions} + selectedBlockId={pinnedBlockId} + lightweight />
    {pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( @@ -193,32 +257,74 @@ export function ExecutionSnapshot({ executionData={blockExecutions[pinnedBlockId]} allBlockExecutions={blockExecutions} workflowBlocks={workflowState.blocks} + workflowVariables={workflowState.variables} + loops={workflowState.loops} + parallels={workflowState.parallels} isExecutionMode + onClose={() => setPinnedBlockId(null)} /> )}
    ) } + const canvasContextMenu = + typeof document !== 'undefined' + ? createPortal( + + + + {contextMenuBlockId && ( + Open Details + )} + Copy Execution ID + + , + document.body + ) + : null + if (isModal) { return ( - { - if (!open) { - setPinnedBlockId(null) - onClose() - } - }} - > - - Workflow State + <> + { + if (!open) { + setPinnedBlockId(null) + onClose() + } + }} + > + + Workflow State - {renderContent()} - - + {renderContent()} + + + {canvasContextMenu} + ) } - return renderContent() + return ( + <> + {renderContent()} + {canvasContextMenu} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index 46a7444ccc..fa16d94240 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -1,13 +1,27 @@ 'use client' import type React from 'react' -import { memo, useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import clsx from 'clsx' -import { ChevronDown, Code } from '@/components/emcn' +import { ArrowDown, ArrowUp, X } from 'lucide-react' +import { createPortal } from 'react-dom' +import { + Button, + ChevronDown, + Code, + Input, + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' import { WorkflowIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { TraceSpan } from '@/stores/logs/filters/types' interface TraceSpansProps { @@ -370,7 +384,7 @@ function SpanContent({ } /** - * Renders input/output section with collapsible content + * Renders input/output section with collapsible content, context menu, and search */ function InputOutputSection({ label, @@ -391,14 +405,63 @@ function InputOutputSection({ }) { const sectionKey = `${spanId}-${sectionType}` const isExpanded = expandedSections.has(sectionKey) + const contentRef = useRef(null) + const menuRef = useRef(null) + + // Context menu state + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + + // Code viewer features + const { + wrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) const jsonString = useMemo(() => { if (!data) return '' return JSON.stringify(data, null, 2) }, [data]) + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + }, []) + + const closeContextMenu = useCallback(() => { + setIsContextMenuOpen(false) + }, []) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(jsonString) + closeContextMenu() + }, [jsonString, closeContextMenu]) + + const handleSearch = useCallback(() => { + activateSearch() + closeContextMenu() + }, [activateSearch, closeContextMenu]) + + const handleToggleWrap = useCallback(() => { + toggleWrapText() + closeContextMenu() + }, [toggleWrapText, closeContextMenu]) + return ( -
    +
    onToggle(sectionKey)} @@ -433,12 +496,101 @@ function InputOutputSection({ />
    {isExpanded && ( - + <> +
    + +
    + + {/* Search Overlay */} + {isSearchActive && ( +
    e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
    + )} + + {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} + {typeof document !== 'undefined' && + createPortal( + + + + Copy + + Search + + Wrap Text + + + , + document.body + )} + )}
    ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index f25a71732f..56c8cdab00 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -87,7 +87,7 @@ export function LogRowContextMenu({ onClose() }} > - Open Preview + Open Snapshot {/* Filter actions */} diff --git a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx index 730c82e54d..f5c1fd0630 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx @@ -210,7 +210,6 @@ function TemplateCardInner({ {normalizedState && isInView ? ( ) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - handleSendMessage() + if (!isStreaming && !isExecuting) { + handleSendMessage() + } } else if (e.key === 'ArrowUp') { e.preventDefault() if (promptHistory.length > 0) { @@ -749,7 +751,7 @@ export function Chat() { } } }, - [handleSendMessage, promptHistory, historyIndex] + [handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting] ) /** @@ -1061,7 +1063,7 @@ export function Chat() { onKeyDown={handleKeyPress} placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'} className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0' - disabled={!activeWorkflowId || isExecuting} + disabled={!activeWorkflowId} /> {/* Buttons positioned absolutely on the right */} @@ -1091,7 +1093,8 @@ export function Chat() { disabled={ (!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || - isExecuting + isExecuting || + isStreaming } className={cn( 'h-[22px] w-[22px] rounded-full p-0 transition-colors', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx index 6ae1f22e09..547098f7bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -118,7 +118,7 @@ export function BlockContextMenu({ {getToggleEnabledLabel()} )} - {!allNoteBlocks && ( + {!allNoteBlocks && !isSubflow && ( { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx index 0683694588..a4cc01b5fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx @@ -4,10 +4,12 @@ import { useState } from 'react' import { Loader2 } from 'lucide-react' import { Button } from '@/components/emcn' import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription' import { useCopilotStore } from '@/stores/panel/copilot/store' +const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) const LIMIT_INCREMENTS = [0, 50, 100] as const function roundUpToNearest50(value: number): number { @@ -15,7 +17,7 @@ function roundUpToNearest50(value: number): number { } export function UsageLimitActions() { - const { data: subscriptionData } = useSubscriptionData() + const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const updateUsageLimitMutation = useUpdateUsageLimit() const subscription = subscriptionData?.data diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 1b931fee56..fecc1327d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Maximize2 } from 'lucide-react' import { @@ -17,6 +17,7 @@ import { Skeleton } from '@/components/ui' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { BlockDetailsSidebar, + getLeftmostBlockId, WorkflowPreview, } from '@/app/workspace/[workspaceId]/w/components/preview' import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' @@ -57,6 +58,7 @@ export function GeneralDeploy({ const [showPromoteDialog, setShowPromoteDialog] = useState(false) const [showExpandedPreview, setShowExpandedPreview] = useState(false) const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState(null) + const hasAutoSelectedRef = useRef(false) const [versionToLoad, setVersionToLoad] = useState(null) const [versionToPromote, setVersionToPromote] = useState(null) @@ -131,6 +133,19 @@ export function GeneralDeploy({ const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0 const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData + // Auto-select the leftmost block once when expanded preview opens + useEffect(() => { + if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) { + hasAutoSelectedRef.current = true + const leftmostId = getLeftmostBlockId(workflowToShow) + setExpandedSelectedBlockId(leftmostId) + } + // Reset when modal closes + if (!showExpandedPreview) { + hasAutoSelectedRef.current = false + } + }, [showExpandedPreview, workflowToShow]) + if (showLoadingSkeleton) { return (
    @@ -186,7 +201,7 @@ export function GeneralDeploy({
    { if (e.ctrlKey || e.metaKey) return e.stopPropagation() @@ -194,28 +209,28 @@ export function GeneralDeploy({ > {workflowToShow ? ( <> - +
    + +
    - Expand preview + See preview ) : ( @@ -316,21 +331,23 @@ export function GeneralDeploy({
    { - setExpandedSelectedBlockId( - expandedSelectedBlockId === blockId ? null : blockId - ) + setExpandedSelectedBlockId(blockId) }} - cursorStyle='pointer' + onPaneClick={() => setExpandedSelectedBlockId(null)} + selectedBlockId={expandedSelectedBlockId} + lightweight />
    {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( setExpandedSelectedBlockId(null)} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx index 3bd4301250..dd7cdeee9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx @@ -488,7 +488,6 @@ const OGCaptureContainer = forwardRef((_, ref) => { > state.getValue(blockId, 'timezone')) + const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as + | string + | undefined const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, { enabled: !isPreview, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6823e303b4..ac23aa5df7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -902,7 +902,22 @@ export function ToolInput({ const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) - const { data: customTools = [] } = useCustomTools(workspaceId) + + const value = isPreview ? previewValue : storeValue + + const selectedTools: StoredTool[] = + Array.isArray(value) && + value.length > 0 && + value[0] !== null && + typeof value[0]?.type === 'string' + ? (value as StoredTool[]) + : [] + + const hasReferenceOnlyCustomTools = selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code + ) + const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools + const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '') const { mcpTools, @@ -918,24 +933,15 @@ export function ToolInput({ const mcpDataLoading = mcpLoading || mcpServersLoading const hasRefreshedRef = useRef(false) - const value = isPreview ? previewValue : storeValue - - const selectedTools: StoredTool[] = - Array.isArray(value) && - value.length > 0 && - value[0] !== null && - typeof value[0]?.type === 'string' - ? (value as StoredTool[]) - : [] - const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp') useEffect(() => { + if (isPreview) return if (hasMcpTools && !hasRefreshedRef.current) { hasRefreshedRef.current = true forceRefreshMcpTools(workspaceId) } - }, [hasMcpTools, forceRefreshMcpTools, workspaceId]) + }, [hasMcpTools, forceRefreshMcpTools, workspaceId, isPreview]) /** * Returns issue info for an MCP tool. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx index af9d11beaf..b66463ca95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx @@ -43,10 +43,12 @@ export function TriggerSave({ const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false) - const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) + const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as + | string + | null const storedTestUrlExpiresAt = useSubBlockStore((state) => state.getValue(blockId, 'testUrlExpiresAt') - ) + ) as string | null const isTestUrlExpired = useMemo(() => { if (!storedTestUrlExpiresAt) return true diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts index 1ea9bf4e86..e79663e5c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts @@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { z-index: 9999; ` - // Create icon container const iconContainer = document.createElement('div') iconContainer.style.cssText = ` width: 24px; @@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { flex-shrink: 0; ` - // Clone the actual icon if provided if (info.iconElement) { const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement clonedIcon.style.width = '16px' @@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { iconContainer.appendChild(clonedIcon) } - // Create text element const text = document.createElement('span') text.textContent = info.name text.style.cssText = ` - color: #FFFFFF; + color: var(--text-primary); font-size: 16px; font-weight: 500; white-space: nowrap; diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts index 2262449303..4bf8846668 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts @@ -1,5 +1,8 @@ +import { getEnv, isTruthy } from '@/lib/core/config/env' import { useSubscriptionData } from '@/hooks/queries/subscription' +const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) + /** * Simplified hook that uses React Query for usage limits. * Provides usage exceeded status from existing subscription data. @@ -12,7 +15,7 @@ export function useUsageLimits(options?: { }) { // For now, we only support user context via React Query // Organization context should use useOrganizationBilling directly - const { data: subscriptionData, isLoading } = useSubscriptionData() + const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled }) const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index e37f4dd88d..fd1aa196ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -47,6 +47,8 @@ export interface SubflowNodeData { parentId?: string extent?: 'parent' isPreview?: boolean + /** Whether this subflow is selected in preview mode */ + isPreviewSelected?: boolean kind: 'loop' | 'parallel' name?: string } @@ -123,15 +125,17 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps void onFilterByStatus: (status: 'error' | 'info') => void onFilterByRunId: (runId: string) => void + onCopyRunId: (runId: string) => void onClearFilters: () => void onClearConsole: () => void hasActiveFilters: boolean @@ -50,6 +51,7 @@ export function LogRowContextMenu({ onFilterByBlock, onFilterByStatus, onFilterByRunId, + onCopyRunId, onClearFilters, onClearConsole, hasActiveFilters, @@ -79,18 +81,18 @@ export function LogRowContextMenu({ }} /> - {/* Clear filters at top when active */} - {hasActiveFilters && ( + {/* Copy actions */} + {entry && hasRunId && ( <> { - onClearFilters() + onCopyRunId(entry.executionId!) onClose() }} > - Clear All Filters + Copy Run ID - {entry && } + )} @@ -129,6 +131,18 @@ export function LogRowContextMenu({ )} + {/* Clear filters */} + {hasActiveFilters && ( + { + onClearFilters() + onClose() + }} + > + Clear All Filters + + )} + {/* Destructive action */} {(entry || hasActiveFilters) && } (null) const outputContentRef = useRef(null) + const { + isSearchActive: isOutputSearchActive, + searchQuery: outputSearchQuery, + setSearchQuery: setOutputSearchQuery, + matchCount, + currentMatchIndex, + activateSearch: activateOutputSearch, + closeSearch: closeOutputSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef: outputSearchInputRef, + } = useCodeViewerFeatures({ + contentRef: outputContentRef, + externalWrapText: wrapText, + onWrapTextChange: setWrapText, + }) - // Training controls state const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false) const showTrainingControls = useGeneralStore((state) => state.showTrainingControls) const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore() - // Playground state const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false) - // Terminal resize hooks const { handleMouseDown } = useTerminalResize() const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize() - // Terminal filters hook const { filters, sortConfig, @@ -370,12 +378,10 @@ export function Terminal() { hasActiveFilters, } = useTerminalFilters() - // Context menu state const [hasSelection, setHasSelection] = useState(false) const [contextMenuEntry, setContextMenuEntry] = useState(null) const [storedSelectionText, setStoredSelectionText] = useState('') - // Context menu hooks const { isOpen: isLogRowMenuOpen, position: logRowMenuPosition, @@ -577,44 +583,6 @@ export function Terminal() { } }, [activeWorkflowId, clearWorkflowConsole]) - const activateOutputSearch = useCallback(() => { - setIsOutputSearchActive(true) - setTimeout(() => { - outputSearchInputRef.current?.focus() - }, 0) - }, []) - - const closeOutputSearch = useCallback(() => { - setIsOutputSearchActive(false) - setOutputSearchQuery('') - setMatchCount(0) - setCurrentMatchIndex(0) - }, []) - - /** - * Navigates to the next match in the search results. - */ - const goToNextMatch = useCallback(() => { - if (matchCount === 0) return - setCurrentMatchIndex((prev) => (prev + 1) % matchCount) - }, [matchCount]) - - /** - * Navigates to the previous match in the search results. - */ - const goToPreviousMatch = useCallback(() => { - if (matchCount === 0) return - setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount) - }, [matchCount]) - - /** - * Handles match count change from Code.Viewer. - */ - const handleMatchCountChange = useCallback((count: number) => { - setMatchCount(count) - setCurrentMatchIndex(0) - }, []) - const handleClearConsole = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -683,6 +651,14 @@ export function Terminal() { [toggleRunId, closeLogRowMenu] ) + const handleCopyRunId = useCallback( + (runId: string) => { + navigator.clipboard.writeText(runId) + closeLogRowMenu() + }, + [closeLogRowMenu] + ) + const handleClearConsoleFromMenu = useCallback(() => { clearCurrentWorkflowConsole() }, [clearCurrentWorkflowConsole]) @@ -885,66 +861,20 @@ export function Terminal() { }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) /** - * Handle Escape to close search or unselect entry - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - // First close search if active - if (isOutputSearchActive) { - closeOutputSearch() - return - } - // Then unselect entry - if (selectedEntry) { - setSelectedEntry(null) - setAutoSelectEnabled(true) - } - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry, isOutputSearchActive, closeOutputSearch]) - - /** - * Handle Enter/Shift+Enter for search navigation when search input is focused + * Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures) */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!isOutputSearchActive) return - - const isSearchInputFocused = document.activeElement === outputSearchInputRef.current - - if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) { + if (e.key === 'Escape' && !isOutputSearchActive && selectedEntry) { e.preventDefault() - if (e.shiftKey) { - goToPreviousMatch() - } else { - goToNextMatch() - } + setSelectedEntry(null) + setAutoSelectEnabled(true) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isOutputSearchActive, matchCount, goToNextMatch, goToPreviousMatch]) - - /** - * Scroll to current match when it changes - */ - useEffect(() => { - if (!isOutputSearchActive || matchCount === 0 || !outputContentRef.current) return - - // Find all match elements and scroll to the current one - const matchElements = outputContentRef.current.querySelectorAll('[data-search-match]') - const currentElement = matchElements[currentMatchIndex] - - if (currentElement) { - currentElement.scrollIntoView({ block: 'center' }) - } - }, [currentMatchIndex, isOutputSearchActive, matchCount]) + }, [selectedEntry, isOutputSearchActive]) /** * Adjust output panel width when sidebar or panel width changes. @@ -1414,25 +1344,16 @@ export function Terminal() {
    {/* Run ID */} - - - - {formatRunId(entry.executionId)} - - - {entry.executionId && ( - - {entry.executionId} - + + style={{ color: runIdColor?.text || '#D2D2D2' }} + > + {formatRunId(entry.executionId)} + {/* Duration */} { e.stopPropagation() @@ -1509,7 +1428,7 @@ export function Terminal() { variant='ghost' className={clsx( 'px-[8px] py-[6px] text-[12px]', - showInput && '!text-[var(--text-primary)]' + showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} onClick={(e) => { e.stopPropagation() @@ -1839,6 +1758,7 @@ export function Terminal() { onFilterByBlock={handleFilterByBlock} onFilterByStatus={handleFilterByStatus} onFilterByRunId={handleFilterByRunId} + onCopyRunId={handleCopyRunId} onClearFilters={() => { clearFilters() closeLogRowMenu() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 3faa5498ac..e861a7e71e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -34,8 +34,8 @@ export const ActionBar = memo( const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() const { activeWorkflowId } = useWorkflowRegistry() const blocks = useWorkflowStore((state) => state.blocks) @@ -121,7 +121,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeToggleBlockEnabled(blockId) + collaborativeBatchToggleBlockEnabled([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' @@ -161,53 +161,53 @@ export const ActionBar = memo( )} - {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( + {!isNoteBlock && ( - {getTooltipMessage('Remove from Subflow')} + + {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} + )} - {!isNoteBlock && ( + {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( - - {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} - + {getTooltipMessage('Remove from Subflow')} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts index 5e1a334a02..160d3b5871 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts @@ -54,9 +54,11 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI useCallback( (state) => { if (!activeWorkflowId) return undefined - return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider?.value as - | string - | undefined + const value = state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider + if (typeof value === 'object' && value !== null && 'value' in value) { + return (value as { value?: unknown }).value as string | undefined + } + return value as string | undefined }, [activeWorkflowId, blockId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index e3e3f0146f..be830b6a49 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -10,6 +10,8 @@ export interface WorkflowBlockProps { isActive?: boolean isPending?: boolean isPreview?: boolean + /** Whether this block is selected in preview mode */ + isPreviewSelected?: boolean subBlockValues?: Record blockState?: any } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts index 1239ec3968..be9c855c95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts @@ -32,6 +32,7 @@ export function shouldSkipBlockRender( prevProps.data.isActive === nextProps.data.isActive && prevProps.data.isPending === nextProps.data.isPending && prevProps.data.isPreview === nextProps.data.isPreview && + prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected && prevProps.data.config === nextProps.data.config && prevProps.data.subBlockValues === nextProps.data.subBlockValues && prevProps.data.blockState === nextProps.data.blockState && diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 42b8484dc1..efa8ddb2d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -624,7 +624,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({ if (!activeWorkflowId) return const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] if (!current) return - const cred = current.credential?.value as string | undefined + const credValue = current.credential + const cred = + typeof credValue === 'object' && credValue !== null && 'value' in credValue + ? ((credValue as { value?: unknown }).value as string | undefined) + : (credValue as string | undefined) if (prevCredRef.current !== cred) { prevCredRef.current = cred const keys = Object.keys(current) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 02d31bd926..b4e7ca1f3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({ }) const isSelected = data?.isSelected ?? false - const isInsideLoop = data?.isInsideLoop ?? false - const parentLoopId = data?.parentLoopId - // Combined store subscription to reduce subscription overhead const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore( useShallow((state) => ({ diffAnalysis: state.diffAnalysis, @@ -98,7 +95,8 @@ const WorkflowEdgeComponent = ({ } else if (edgeDiffStatus === 'new') { color = 'var(--brand-tertiary)' } else if (edgeRunStatus === 'success') { - color = 'var(--border-success)' + // Use green for preview mode, default for canvas execution + color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)' } else if (edgeRunStatus === 'error') { color = 'var(--text-error)' } @@ -120,34 +118,18 @@ const WorkflowEdgeComponent = ({ strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined, opacity, } - }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus]) + }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus]) return ( <> - - {/* Animate dash offset for edge movement effect */} - + {isSelected && (
    state.activeWorkflowId) @@ -40,14 +41,13 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis isDeletedBlock, } = useBlockState(blockId, currentWorkflow, data) - const isActive = isPreview ? false : blockIsActive + // In preview mode, use isPreviewSelected for selection state + const isActive = isPreview ? isPreviewSelected : blockIsActive const lastRunPath = useExecutionStore((state) => state.lastRunPath) const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId) const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId) - const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) - const isFocused = isPreview ? false : currentBlockId === blockId const handleClick = useCallback(() => { if (!isPreview) { @@ -60,12 +60,12 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis getBlockRingStyles({ isActive, isPending: isPreview ? false : isPending, - isFocused, isDeletedBlock: isPreview ? false : isDeletedBlock, diffStatus: isPreview ? undefined : diffStatus, runPathStatus, + isPreviewSelection: isPreview && isPreviewSelected, }), - [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview] + [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 5665789235..ffa148d881 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { useReactFlow } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { getBlock } from '@/blocks/registry' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('NodeUtilities') @@ -208,28 +209,30 @@ export function useNodeUtilities(blocks: Record) { * to the content area bounds (after header and padding). * @param nodeId ID of the node being repositioned * @param newParentId ID of the new parent + * @param skipClamping If true, returns raw relative position without clamping to container bounds * @returns Relative position coordinates {x, y} within the parent */ const calculateRelativePosition = useCallback( - (nodeId: string, newParentId: string): { x: number; y: number } => { + (nodeId: string, newParentId: string, skipClamping?: boolean): { x: number; y: number } => { const nodeAbsPos = getNodeAbsolutePosition(nodeId) const parentAbsPos = getNodeAbsolutePosition(newParentId) - const parentNode = getNodes().find((n) => n.id === newParentId) - // Calculate raw relative position (relative to parent origin) const rawPosition = { x: nodeAbsPos.x - parentAbsPos.x, y: nodeAbsPos.y - parentAbsPos.y, } - // Get container and block dimensions + if (skipClamping) { + return rawPosition + } + + const parentNode = getNodes().find((n) => n.id === newParentId) const containerDimensions = { width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } const blockDimensions = getBlockDimensions(nodeId) - // Clamp position to keep block inside content area return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions) }, [getNodeAbsolutePosition, getNodes, getBlockDimensions] @@ -298,12 +301,12 @@ export function useNodeUtilities(blocks: Record) { */ const calculateLoopDimensions = useCallback( (nodeId: string): { width: number; height: number } => { - // Check both React Flow's node.parentId AND blocks store's data.parentId - // This ensures we catch children even if React Flow hasn't re-rendered yet - const childNodes = getNodes().filter( - (node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId + const currentBlocks = useWorkflowStore.getState().blocks + const childBlockIds = Object.keys(currentBlocks).filter( + (id) => currentBlocks[id]?.data?.parentId === nodeId ) - if (childNodes.length === 0) { + + if (childBlockIds.length === 0) { return { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, @@ -313,30 +316,28 @@ export function useNodeUtilities(blocks: Record) { let maxRight = 0 let maxBottom = 0 - childNodes.forEach((node) => { - const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - // Use block position from store if available (more up-to-date) - const block = blocks[node.id] - const position = block?.position || node.position - maxRight = Math.max(maxRight, position.x + nodeWidth) - maxBottom = Math.max(maxBottom, position.y + nodeHeight) - }) + for (const childId of childBlockIds) { + const child = currentBlocks[childId] + if (!child?.position) continue + + const { width: childWidth, height: childHeight } = getBlockDimensions(childId) + + maxRight = Math.max(maxRight, child.position.x + childWidth) + maxBottom = Math.max(maxBottom, child.position.y + childHeight) + } const width = Math.max( CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING ) const height = Math.max( CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING ) return { width, height } }, - [getNodes, getBlockDimensions, blocks] + [getBlockDimensions] ) /** @@ -345,29 +346,27 @@ export function useNodeUtilities(blocks: Record) { */ const resizeLoopNodes = useCallback( (updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => { - const containerNodes = getNodes() - .filter((node) => node.type && isContainerType(node.type)) - .map((node) => ({ - ...node, - depth: getNodeDepth(node.id), + const currentBlocks = useWorkflowStore.getState().blocks + const containerBlocks = Object.entries(currentBlocks) + .filter(([, block]) => block?.type && isContainerType(block.type)) + .map(([id, block]) => ({ + id, + block, + depth: getNodeDepth(id), })) - // Sort by depth descending - process innermost containers first - // so their dimensions are correct when outer containers calculate sizes .sort((a, b) => b.depth - a.depth) - containerNodes.forEach((node) => { - const dimensions = calculateLoopDimensions(node.id) - // Get current dimensions from the blocks store rather than React Flow's potentially stale state - const currentWidth = blocks[node.id]?.data?.width - const currentHeight = blocks[node.id]?.data?.height + for (const { id, block } of containerBlocks) { + const dimensions = calculateLoopDimensions(id) + const currentWidth = block?.data?.width + const currentHeight = block?.data?.height - // Only update if dimensions actually changed to avoid unnecessary re-renders if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) { - updateNodeDimensions(node.id, dimensions) + updateNodeDimensions(id, dimensions) } - }) + } }, - [getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks] + [isContainerType, getNodeDepth, calculateLoopDimensions] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts index 1b532c694f..1490d6040b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -7,66 +7,64 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined export interface BlockRingOptions { isActive: boolean isPending: boolean - isFocused: boolean isDeletedBlock: boolean diffStatus: BlockDiffStatus runPathStatus: BlockRunPathStatus + isPreviewSelection?: boolean } /** * Derives visual ring visibility and class names for workflow blocks - * based on execution, focus, diff, deletion, and run-path states. + * based on execution, diff, deletion, and run-path states. */ export function getBlockRingStyles(options: BlockRingOptions): { hasRing: boolean ringClassName: string } { - const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options + const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } = + options const hasRing = isActive || isPending || - isFocused || diffStatus === 'new' || diffStatus === 'edited' || isDeletedBlock || !!runPathStatus const ringClassName = cn( + // Preview selection: static blue ring (standard thickness, no animation) + isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]', // Executing block: pulsing success ring with prominent thickness - isActive && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse', + isActive && + !isPreviewSelection && + 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse', // Non-active states use standard ring utilities !isActive && hasRing && 'ring-[1.75px]', // Pending state: warning ring !isActive && isPending && 'ring-[var(--warning)]', - // Focused (selected) state: brand ring - !isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]', - // Deleted state (highest priority after active/pending/focused) - !isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]', + // Deleted state (highest priority after active/pending) + !isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]', // Diff states !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'new' && 'ring-[var(--brand-tertiary)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'edited' && 'ring-[var(--warning)]', // Run path states (lowest priority - only show if no other states active) !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'success' && 'ring-[var(--border-success)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'error' && diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts new file mode 100644 index 0000000000..a0f2a57722 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -0,0 +1,181 @@ +import type { Edge, Node } from 'reactflow' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Checks if the currently focused element is an editable input. + * Returns true if the user is typing in an input, textarea, or contenteditable element. + */ +export function isInEditableElement(): boolean { + const activeElement = document.activeElement + return ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.hasAttribute('contenteditable') === true + ) +} + +interface TriggerValidationResult { + isValid: boolean + message?: string +} + +/** + * Validates that pasting/duplicating trigger blocks won't violate constraints. + * Returns validation result with error message if invalid. + */ +export function validateTriggerPaste( + blocksToAdd: Array<{ type: string }>, + existingBlocks: Record, + action: 'paste' | 'duplicate' +): TriggerValidationResult { + for (const block of blocksToAdd) { + if (TriggerUtils.isAnyTriggerType(block.type)) { + const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type) + if (issue) { + const actionText = action === 'paste' ? 'paste' : 'duplicate' + const message = + issue.issue === 'legacy' + ? `Cannot ${actionText} trigger blocks when a legacy Start block exists.` + : `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}` + return { isValid: false, message } + } + } + } + return { isValid: true } +} + +/** + * Clears drag highlight classes and resets cursor state. + * Used when drag operations end or are cancelled. + */ +export function clearDragHighlights(): void { + document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + document.body.style.cursor = '' +} + +/** + * Selects nodes by their IDs after paste/duplicate operations. + * Defers selection to next animation frame to allow displayNodes to sync from store first. + * This is necessary because the component uses controlled state (nodes={displayNodes}) + * and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle. + */ +export function selectNodesDeferred( + nodeIds: string[], + setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void +): void { + const idsSet = new Set(nodeIds) + requestAnimationFrame(() => { + setDisplayNodes((nodes) => + nodes.map((node) => ({ + ...node, + selected: idsSet.has(node.id), + })) + ) + }) +} + +interface BlockData { + height?: number + data?: { + parentId?: string + width?: number + height?: number + } +} + +/** + * Calculates the final position for a node, clamping it to parent container if needed. + * Returns the clamped position suitable for persistence. + */ +export function getClampedPositionForNode( + nodeId: string, + nodePosition: { x: number; y: number }, + blocks: Record, + allNodes: Node[] +): { x: number; y: number } { + const currentBlock = blocks[nodeId] + const currentParentId = currentBlock?.data?.parentId + + if (!currentParentId) { + return nodePosition + } + + const parentNode = allNodes.find((n) => n.id === currentParentId) + if (!parentNode) { + return nodePosition + } + + const containerDimensions = { + width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + const blockDimensions = { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max( + currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ), + } + + return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions) +} + +/** + * Computes position updates for multiple nodes, clamping each to its parent container. + * Used for batch position updates after multi-node drag or selection drag. + */ +export function computeClampedPositionUpdates( + nodes: Node[], + blocks: Record, + allNodes: Node[] +): Array<{ id: string; position: { x: number; y: number } }> { + return nodes.map((node) => ({ + id: node.id, + position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), + })) +} + +interface ParentUpdateEntry { + blockId: string + newParentId: string + affectedEdges: Edge[] +} + +/** + * Computes parent update entries for nodes being moved into a subflow. + * Only includes "boundary edges" - edges that cross the selection boundary + * (one end inside selection, one end outside). Edges between nodes in the + * selection are preserved. + */ +export function computeParentUpdateEntries( + validNodes: Node[], + allEdges: Edge[], + targetParentId: string +): ParentUpdateEntry[] { + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + + // Find edges that cross the boundary (one end inside selection, one end outside) + // Edges between nodes in the selection should stay intact + const boundaryEdges = allEdges.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + // Only remove if exactly one end is in the selection (crosses boundary) + return sourceInSelection !== targetInSelection + }) + + // Build updates for all valid nodes + return validNodes.map((n) => { + // Only include boundary edges connected to this specific node + const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id) + return { + blockId: n.id, + newParentId: targetParentId, + affectedEdges: edgesForThisNode, + } + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b906664058..876ee009a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -11,6 +11,7 @@ import ReactFlow, { type NodeChange, type NodeTypes, ReactFlowProvider, + SelectionMode, useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' @@ -42,9 +43,15 @@ import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { + clearDragHighlights, + computeClampedPositionUpdates, + getClampedPositionForNode, + isInEditableElement, + selectNodesDeferred, useAutoLayout, useCurrentWorkflow, useNodeUtilities, + validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu' import { @@ -180,11 +187,12 @@ const reactFlowStyles = [ const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const const reactFlowProOptions = { hideAttribution: true } as const -interface SelectedEdgeInfo { - id: string - parentLoopId?: string - contextId?: string -} +/** + * Map from edge contextId to edge id. + * Context IDs include parent loop info for edges inside loops. + * The actual edge ID is stored as the value for deletion operations. + */ +type SelectedEdgesMap = Map interface BlockData { id: string @@ -200,7 +208,7 @@ interface BlockData { const WorkflowContent = React.memo(() => { const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) - const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + const [selectedEdges, setSelectedEdges] = useState(new Map()) const [isShiftPressed, setIsShiftPressed] = useState(false) const [isSelectionDragActive, setIsSelectionDragActive] = useState(false) const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) @@ -280,7 +288,7 @@ const WorkflowContent = React.memo(() => { useStreamCleanup(copilotCleanup) - const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow + const { blocks, edges, lastSaved } = currentWorkflow const isWorkflowReady = useMemo( () => @@ -295,6 +303,7 @@ const WorkflowContent = React.memo(() => { const { getNodeDepth, getNodeAbsolutePosition, + calculateRelativePosition, isPointInLoopNode, resizeLoopNodes, updateNodeParent: updateNodeParentUtil, @@ -343,6 +352,11 @@ const WorkflowContent = React.memo(() => { /** Stores source node/handle info when a connection drag starts for drop-on-block detection. */ const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null) + /** Stores start positions for multi-node drag undo/redo recording. */ + const multiNodeDragStartRef = useRef>( + new Map() + ) + /** Re-applies diff markers when blocks change after socket rehydration. */ const blocksRef = useRef(blocks) useEffect(() => { @@ -431,12 +445,14 @@ const WorkflowContent = React.memo(() => { const { collaborativeAddEdge: addEdge, collaborativeRemoveEdge: removeEdge, + collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, + collaborativeBatchUpdateParent, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, undo, redo, } = useCollaborativeWorkflow() @@ -636,22 +652,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -661,6 +669,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ hasClipboard, clipboard, @@ -687,22 +700,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot duplicate trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -712,6 +717,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ contextMenuBlocks, copyBlocks, @@ -728,25 +738,26 @@ const WorkflowContent = React.memo(() => { }, [contextMenuBlocks, collaborativeBatchRemoveBlocks]) const handleContextToggleEnabled = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockEnabled(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockEnabled]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockEnabled(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockEnabled]) const handleContextToggleHandles = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockHandles(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockHandles]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockHandles(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) const handleContextRemoveFromSubflow = useCallback(() => { - contextMenuBlocks.forEach((block) => { - if (block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')) { - window.dispatchEvent( - new CustomEvent('remove-from-subflow', { detail: { blockId: block.id } }) - ) - } - }) + const blocksToRemove = contextMenuBlocks.filter( + (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') + ) + if (blocksToRemove.length > 0) { + window.dispatchEvent( + new CustomEvent('remove-from-subflow', { + detail: { blockIds: blocksToRemove.map((b) => b.id) }, + }) + ) + } }, [contextMenuBlocks]) const handleContextOpenEditor = useCallback(() => { @@ -788,13 +799,7 @@ const WorkflowContent = React.memo(() => { let cleanup: (() => void) | null = null const handleKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { event.stopPropagation() return } @@ -840,22 +845,14 @@ const WorkflowContent = React.memo(() => { const pasteData = preparePasteData(pasteOffset) if (pasteData) { const pastedBlocks = Object.values(pasteData.blocks) - for (const block of pastedBlocks) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -865,6 +862,11 @@ const WorkflowContent = React.memo(() => { pasteData.parallels, pasteData.subBlockValues ) + + selectNodesDeferred( + pastedBlocks.map((b) => b.id), + setDisplayNodes + ) } } } @@ -919,33 +921,6 @@ const WorkflowContent = React.memo(() => { [removeEdge] ) - /** Handles ActionBar remove-from-subflow events. */ - useEffect(() => { - const handleRemoveFromSubflow = (event: Event) => { - const customEvent = event as CustomEvent<{ blockId: string }> - const blockId = customEvent.detail?.blockId - if (!blockId) return - - try { - const currentBlock = blocks[blockId] - const parentId = currentBlock?.data?.parentId - if (!parentId) return - - const edgesToRemove = edgesForDisplay.filter( - (e) => e.source === blockId || e.target === blockId - ) - removeEdgesForNode(blockId, edgesToRemove) - updateNodeParent(blockId, null, edgesToRemove) - } catch (err) { - logger.error('Failed to remove from subflow', { err }) - } - } - - window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) - return () => - window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) - }, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent]) - /** Finds the closest block to a position for auto-connect. */ const findClosestOutput = useCallback( (newNodePosition: { x: number; y: number }): BlockData | null => { @@ -1168,10 +1143,7 @@ const WorkflowContent = React.memo(() => { try { const containerInfo = isPointInLoopNode(position) - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')) - document.body.style.cursor = '' + clearDragHighlights() document.body.classList.remove('sim-drag-subflow') if (data.type === 'loop' || data.type === 'parallel') { @@ -1611,11 +1583,7 @@ const WorkflowContent = React.memo(() => { const containerInfo = isPointInLoopNode(position) // Clear any previous highlighting - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) + clearDragHighlights() // Highlight container if hovering over it and not dragging a subflow // Subflow drag is marked by body class flag set by toolbar @@ -1815,7 +1783,7 @@ const WorkflowContent = React.memo(() => { const nodeArray: Node[] = [] // Add block nodes - Object.entries(blocks).forEach(([blockId, block]) => { + Object.entries(blocks).forEach(([, block]) => { if (!block || !block.type || !block.name) { return } @@ -1892,8 +1860,11 @@ const WorkflowContent = React.memo(() => { }, // Include dynamic dimensions for container resizing calculations (must match rendered size) // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions + // Use estimated dimensions for blocks without measured height to ensure selection bounds are correct width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT), + height: block.height + ? Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT) + : estimateBlockDimensions(block.type).height, }) }) @@ -1945,9 +1916,77 @@ const WorkflowContent = React.memo(() => { }, [isShiftPressed]) useEffect(() => { - setDisplayNodes(derivedNodes) + // Preserve selection state when syncing from derivedNodes + setDisplayNodes((currentNodes) => { + const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) + return derivedNodes.map((node) => ({ + ...node, + selected: selectedIds.has(node.id), + })) + }) }, [derivedNodes]) + /** Handles ActionBar remove-from-subflow events. */ + useEffect(() => { + const handleRemoveFromSubflow = (event: Event) => { + const customEvent = event as CustomEvent<{ blockIds: string[] }> + const blockIds = customEvent.detail?.blockIds + if (!blockIds || blockIds.length === 0) return + + try { + const validBlockIds = blockIds.filter((id) => { + const block = blocks[id] + return block?.data?.parentId + }) + if (validBlockIds.length === 0) return + + const movingNodeIds = new Set(validBlockIds) + + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + + // Collect absolute positions BEFORE updating parents + const absolutePositions = new Map() + for (const blockId of validBlockIds) { + absolutePositions.set(blockId, getNodeAbsolutePosition(blockId)) + } + + for (const blockId of validBlockIds) { + const edgesForThisNode = boundaryEdges.filter( + (e) => e.source === blockId || e.target === blockId + ) + removeEdgesForNode(blockId, edgesForThisNode) + updateNodeParent(blockId, null, edgesForThisNode) + } + + // Immediately update displayNodes to prevent React Flow from using stale parent data + setDisplayNodes((nodes) => + nodes.map((n) => { + const absPos = absolutePositions.get(n.id) + if (absPos) { + return { + ...n, + position: absPos, + parentId: undefined, + extent: undefined, + } + } + return n + }) + ) + } catch (err) { + logger.error('Failed to remove from subflow', { err }) + } + } + + window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) + return () => + window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) + }, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent, getNodeAbsolutePosition]) + /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ const onNodesChange = useCallback((changes: NodeChange[]) => { setDisplayNodes((nds) => applyNodeChanges(changes, nds)) @@ -2259,12 +2298,8 @@ const WorkflowContent = React.memo(() => { if (isStarterBlock) { // If it's a starter block, remove any highlighting and don't allow it to be dragged into containers if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - don't process any container intersections for starter blocks } @@ -2276,12 +2311,8 @@ const WorkflowContent = React.memo(() => { if (node.type === 'subflowNode') { // Clear any highlighting for subflow nodes if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - subflows cannot be placed inside other subflows } @@ -2292,9 +2323,6 @@ const WorkflowContent = React.memo(() => { // Only consider container nodes that aren't the dragged node if (n.type !== 'subflowNode' || n.id === node.id) return false - // Skip if this container is already the parent of the node being dragged - if (n.id === currentParentId) return false - // Get the container's absolute position const containerAbsolutePos = getNodeAbsolutePosition(n.id) @@ -2382,12 +2410,8 @@ const WorkflowContent = React.memo(() => { } else { // Remove highlighting if no longer over a container if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } } }, @@ -2407,6 +2431,8 @@ const WorkflowContent = React.memo(() => { // Store the original parent ID when starting to drag const currentParentId = blocks[node.id]?.data?.parentId || null setDragStartParentId(currentParentId) + // Initialize potentialParentId to the current parent so a click without movement doesn't remove from subflow + setPotentialParentId(currentParentId) // Store starting position for undo/redo move entry setDragStartPosition({ id: node.id, @@ -2414,49 +2440,149 @@ const WorkflowContent = React.memo(() => { y: node.position.y, parentId: currentParentId, }) + + // Capture all selected nodes' positions for multi-node undo/redo + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) + multiNodeDragStartRef.current.clear() + selectedNodes.forEach((n) => { + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } + }) }, - [blocks, setDragStartPosition] + [blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId] ) /** Handles node drag stop to establish parent-child relationships. */ const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: any) => { - // Clear UI effects - document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) - document.body.style.cursor = '' + clearDragHighlights() - // Get the block's current parent (if any) - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId + // Get all selected nodes to update their positions too + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) - // Calculate position - clamp if inside a container - let finalPosition = node.position - if (currentParentId) { - // Block is inside a container - clamp position to keep it fully inside - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } + // If multiple nodes are selected, update all their positions + if (selectedNodes.length > 1) { + const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + previousPositions: multiNodeDragStartRef.current, + }) - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions - ) + // 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, + }) + } } + + // Clear drag start state + setDragStartPosition(null) + setPotentialParentId(null) + multiNodeDragStartRef.current.clear() + return } + // Single node drag - original logic + const finalPosition = getClampedPositionForNode(node.id, node.position, blocks, allNodes) + updateBlockPosition(node.id, finalPosition) // Record single move entry on drag end to avoid micro-moves @@ -2564,10 +2690,66 @@ const WorkflowContent = React.memo(() => { const affectedEdges = [...edgesToRemove, ...edgesToAdd] updateNodeParent(node.id, potentialParentId, affectedEdges) + setDisplayNodes((nodes) => + nodes.map((n) => { + if (n.id === node.id) { + return { + ...n, + position: relativePositionBefore, + parentId: potentialParentId, + extent: 'parent' as const, + } + } + return n + }) + ) + // Now add the edges after parent update edgesToAdd.forEach((edge) => addEdge(edge)) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) + } else if (!potentialParentId && dragStartParentId) { + // Moving OUT of a subflow to canvas + // Get absolute position BEFORE removing from parent + const absolutePosition = getNodeAbsolutePosition(node.id) + + // Remove edges connected to this node since it's leaving its parent + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === node.id || e.target === node.id + ) + + if (edgesToRemove.length > 0) { + removeEdgesForNode(node.id, edgesToRemove) + + logger.info('Removed edges when moving node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + edgeCount: edgesToRemove.length, + }) + } + + // Clear the parent relationship + updateNodeParent(node.id, null, edgesToRemove) + + // Immediately update displayNodes to prevent React Flow from using stale parent data + setDisplayNodes((nodes) => + nodes.map((n) => { + if (n.id === node.id) { + return { + ...n, + position: absolutePosition, + parentId: undefined, + extent: undefined, + } + } + return n + }) + ) + + logger.info('Moved node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + }) } // Reset state @@ -2585,10 +2767,14 @@ const WorkflowContent = React.memo(() => { edgesForDisplay, removeEdgesForNode, getNodeAbsolutePosition, + calculateRelativePosition, + resizeLoopNodesWrapper, getDragStartPosition, setDragStartPosition, addNotification, activeWorkflowId, + collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, ] ) @@ -2603,52 +2789,306 @@ const WorkflowContent = React.memo(() => { requestAnimationFrame(() => setIsSelectionDragActive(false)) }, []) + /** Captures initial positions when selection drag starts (for marquee-selected nodes). */ + const onSelectionDragStart = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + // Capture the parent ID of the first node as reference (they should all be in the same context) + if (nodes.length > 0) { + const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null + setDragStartParentId(firstNodeParentId) + } + + // Capture all selected nodes' positions for undo/redo + multiNodeDragStartRef.current.clear() + nodes.forEach((n) => { + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } + }) + }, + [blocks] + ) + + /** Handles selection drag to detect potential parent containers for batch drops. */ + const onSelectionDrag = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + 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 + }) + + // If no eligible nodes, clear any potential parent + if (eligibleNodes.length === 0) { + if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + return + } + + // Calculate bounding box of all dragged nodes using absolute positions + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + eligibleNodes.forEach((node) => { + const absolutePos = getNodeAbsolutePosition(node.id) + const block = blocks[node.id] + const width = BLOCK_DIMENSIONS.FIXED_WIDTH + const height = Math.max( + node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ) + + minX = Math.min(minX, absolutePos.x) + minY = Math.min(minY, absolutePos.y) + maxX = Math.max(maxX, absolutePos.x + width) + maxY = Math.max(maxY, absolutePos.y + height) + }) + + // Use bounding box for intersection detection + const selectionRect = { left: minX, right: maxX, top: minY, bottom: maxY } + + // Find containers that intersect with the selection bounding box + const allNodes = getNodes() + const intersectingContainers = allNodes + .filter((containerNode) => { + if (containerNode.type !== 'subflowNode') return false + // Skip if any dragged node is this container + if (nodes.some((n) => n.id === containerNode.id)) return false + + const containerAbsolutePos = getNodeAbsolutePosition(containerNode.id) + const containerRect = { + left: containerAbsolutePos.x, + right: + containerAbsolutePos.x + + (containerNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + top: containerAbsolutePos.y, + bottom: + containerAbsolutePos.y + + (containerNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + } + + // Check intersection + return ( + selectionRect.left < containerRect.right && + selectionRect.right > containerRect.left && + selectionRect.top < containerRect.bottom && + selectionRect.bottom > containerRect.top + ) + }) + .map((n) => ({ + container: n, + depth: getNodeDepth(n.id), + size: + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) * + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + })) + + if (intersectingContainers.length > 0) { + // Sort by depth first (deepest first), then by size + const sortedContainers = intersectingContainers.sort((a, b) => { + if (a.depth !== b.depth) return b.depth - a.depth + return a.size - b.size + }) + + 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' + } + } + } else if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + }, + [ + blocks, + getNodes, + potentialParentId, + getNodeAbsolutePosition, + getNodeDepth, + clearDragHighlights, + ] + ) + const onSelectionDragStop = useCallback( (_event: React.MouseEvent, nodes: any[]) => { requestAnimationFrame(() => setIsSelectionDragActive(false)) + clearDragHighlights() if (nodes.length === 0) return - const positionUpdates = nodes.map((node) => { - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId - let finalPosition = node.position - - if (currentParentId) { - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions + const allNodes = getNodes() + const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + 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, + }, + })) } - } - return { id: node.id, position: finalPosition } - }) + collaborativeBatchUpdateParent(updates) - collaborativeBatchUpdatePositions(positionUpdates) + 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, + }) + } + } + + // Clear drag state + setDragStartPosition(null) + setPotentialParentId(null) + multiNodeDragStartRef.current.clear() }, - [blocks, getNodes, collaborativeBatchUpdatePositions] + [ + blocks, + getNodes, + getNodeAbsolutePosition, + collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, + calculateRelativePosition, + resizeLoopNodesWrapper, + potentialParentId, + edgesForDisplay, + clearDragHighlights, + ] ) const onPaneClick = useCallback(() => { - setSelectedEdgeInfo(null) + setSelectedEdges(new Map()) usePanelEditorStore.getState().clearCurrentBlock() }, []) - /** Handles edge selection with container context tracking. */ + /** + * Handles node click to select the node in ReactFlow. + * This ensures clicking anywhere on a block (not just the drag handle) + * selects it for delete/backspace and multi-select operations. + */ + const handleNodeClick = useCallback( + (event: React.MouseEvent, node: Node) => { + const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey + + setNodes((nodes) => + nodes.map((n) => ({ + ...n, + selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, + })) + ) + }, + [setNodes] + ) + + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { event.stopPropagation() // Prevent bubbling @@ -2664,11 +3104,21 @@ const WorkflowContent = React.memo(() => { // Create a unique identifier that combines edge ID and parent context const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` - setSelectedEdgeInfo({ - id: edge.id, - parentLoopId, - contextId, - }) + if (event.shiftKey) { + // Shift-click: toggle edge in selection + setSelectedEdges((prev) => { + const next = new Map(prev) + if (next.has(contextId)) { + next.delete(contextId) + } else { + next.set(contextId, edge.id) + } + return next + }) + } else { + // Normal click: replace selection with this edge + setSelectedEdges(new Map([[contextId, edge.id]])) + } }, [getNodes] ) @@ -2677,14 +3127,22 @@ const WorkflowContent = React.memo(() => { const handleEdgeDelete = useCallback( (edgeId: string) => { removeEdge(edgeId) - setSelectedEdgeInfo((current) => (current?.id === edgeId ? null : current)) + // Remove this edge from selection (find by edge ID value) + setSelectedEdges((prev) => { + const next = new Map(prev) + for (const [contextId, id] of next) { + if (id === edgeId) { + next.delete(contextId) + } + } + return next + }) }, [removeEdge] ) /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ const edgesWithSelection = useMemo(() => { - // Build node lookup map once - O(n) instead of O(n) per edge const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) return edgesForDisplay.map((edge) => { @@ -2697,7 +3155,7 @@ const WorkflowContent = React.memo(() => { ...edge, data: { ...edge.data, - isSelected: selectedEdgeInfo?.contextId === edgeContextId, + isSelected: selectedEdges.has(edgeContextId), isInsideLoop: Boolean(parentLoopId), parentLoopId, sourceHandle: edge.sourceHandle, @@ -2705,7 +3163,7 @@ const WorkflowContent = React.memo(() => { }, } }) - }, [edgesForDisplay, displayNodes, selectedEdgeInfo?.contextId, handleEdgeDelete]) + }, [edgesForDisplay, displayNodes, selectedEdges, handleEdgeDelete]) /** Handles Delete/Backspace to remove selected edges or blocks. */ useEffect(() => { @@ -2715,20 +3173,16 @@ const WorkflowContent = React.memo(() => { } // Ignore when typing/navigating inside editable inputs or editors - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { return } // Handle edge deletion first (edges take priority if selected) - if (selectedEdgeInfo) { - removeEdge(selectedEdgeInfo.id) - setSelectedEdgeInfo(null) + if (selectedEdges.size > 0) { + // Get all selected edge IDs and batch delete them + const edgeIds = Array.from(selectedEdges.values()) + collaborativeBatchRemoveEdges(edgeIds) + setSelectedEdges(new Map()) return } @@ -2750,8 +3204,8 @@ const WorkflowContent = React.memo(() => { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [ - selectedEdgeInfo, - removeEdge, + selectedEdges, + collaborativeBatchRemoveEdges, getNodes, collaborativeBatchRemoveBlocks, effectivePermissions.canEdit, @@ -2808,6 +3262,7 @@ const WorkflowContent = React.memo(() => { connectionLineType={ConnectionLineType.SmoothStep} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} + onNodeClick={handleNodeClick} onPaneContextMenu={handlePaneContextMenu} onNodeContextMenu={handleNodeContextMenu} onSelectionContextMenu={handleSelectionContextMenu} @@ -2815,10 +3270,11 @@ const WorkflowContent = React.memo(() => { onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} selectionOnDrag={isShiftPressed || isSelectionDragActive} + selectionMode={SelectionMode.Partial} panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]} onSelectionStart={onSelectionStart} onSelectionEnd={onSelectionEnd} - multiSelectionKeyCode={['Meta', 'Control']} + multiSelectionKeyCode={['Meta', 'Control', 'Shift']} nodesConnectable={effectivePermissions.canEdit} nodesDraggable={effectivePermissions.canEdit} draggable={false} @@ -2828,6 +3284,8 @@ const WorkflowContent = React.memo(() => { className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`} onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined} + onSelectionDrag={effectivePermissions.canEdit ? onSelectionDrag : undefined} onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined} onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} snapToGrid={snapToGrid} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx index 2db914d741..5a48723c68 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx @@ -1,17 +1,28 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' -import { ChevronDown as ChevronDownIcon, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowDown, + ArrowUp, + ChevronDown as ChevronDownIcon, + ChevronUp, + RepeatIcon, + SplitIcon, + X, +} from 'lucide-react' import { ReactFlowProvider } from 'reactflow' -import { Badge, Button, ChevronDown, Code } from '@/components/emcn' +import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references' +import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { getBlock } from '@/blocks' import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' import { normalizeName } from '@/executor/constants' import { navigatePath } from '@/executor/variables/resolvers/reference' -import type { BlockState } from '@/stores/workflows/workflow/types' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' /** * Evaluate whether a subblock's condition is met based on current values. @@ -87,16 +98,37 @@ interface ResolvedConnection { fields: Array<{ path: string; value: string; tag: string }> } +interface ExtractedReferences { + blockRefs: string[] + workflowVars: string[] + envVars: string[] +} + /** * Extract all variable references from nested subblock values */ -function extractAllReferencesFromSubBlocks(subBlockValues: Record): string[] { - const refs = new Set() +function extractAllReferencesFromSubBlocks( + subBlockValues: Record +): ExtractedReferences { + const blockRefs = new Set() + const workflowVars = new Set() + const envVars = new Set() const processValue = (value: unknown) => { if (typeof value === 'string') { const extracted = extractReferencePrefixes(value) - extracted.forEach((ref) => refs.add(ref.raw)) + for (const ref of extracted) { + if (ref.prefix === 'variable') { + workflowVars.add(ref.raw) + } else { + blockRefs.add(ref.raw) + } + } + + const envMatches = value.match(/\{\{([^}]+)\}\}/g) + if (envMatches) { + envMatches.forEach((match) => envVars.add(match)) + } } else if (Array.isArray(value)) { value.forEach(processValue) } else if (value && typeof value === 'object') { @@ -109,7 +141,11 @@ function extractAllReferencesFromSubBlocks(subBlockValues: Record void + contentRef?: React.RefObject + onContextMenu?: (e: React.MouseEvent) => void } /** * Collapsible section for execution data (input/output) * Uses Code.Viewer for proper syntax highlighting matching the logs UI */ -function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) { +function ExecutionDataSection({ + title, + data, + isError = false, + wrapText = true, + searchQuery, + currentMatchIndex = 0, + onMatchCountChange, + contentRef, + onContextMenu, +}: ExecutionDataSectionProps) { const [isExpanded, setIsExpanded] = useState(false) const jsonString = useMemo(() => { @@ -192,12 +244,17 @@ function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSec No data
    ) : ( - +
    + +
    )} )} @@ -205,18 +262,54 @@ function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSec ) } +interface ResolvedVariable { + ref: string + name: string + value: string +} + +interface ConnectionsSectionProps { + connections: ResolvedConnection[] + workflowVars: ResolvedVariable[] + envVars: ResolvedVariable[] + onContextMenu?: (e: React.MouseEvent, value: string) => void + /** Height of the connections section */ + height: number + /** Whether the section is being resized */ + isResizing: boolean + /** Whether the connections are at minimum height (collapsed) */ + isAtMinHeight: boolean + /** Handler for resize mouse down */ + onResizeMouseDown: (e: React.MouseEvent) => void + /** Handler for toggling collapsed state */ + onToggleCollapsed: () => void +} + /** * Section showing resolved variable references - styled like the connections section in editor */ -function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) { - const [isCollapsed, setIsCollapsed] = useState(false) +function ConnectionsSection({ + connections, + workflowVars, + envVars, + onContextMenu, + height, + isResizing, + isAtMinHeight, + onResizeMouseDown, + onToggleCollapsed, +}: ConnectionsSectionProps) { const [expandedBlocks, setExpandedBlocks] = useState>(new Set()) + const [expandedVariables, setExpandedVariables] = useState(true) + const [expandedEnvVars, setExpandedEnvVars] = useState(true) useEffect(() => { setExpandedBlocks(new Set(connections.map((c) => c.blockId))) }, [connections]) - if (connections.length === 0) return null + const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0 + + if (!hasContent) return null const toggleBlock = (blockId: string) => { setExpandedBlocks((prev) => { @@ -230,110 +323,220 @@ function ResolvedConnectionsSection({ connections }: { connections: ResolvedConn }) } + const handleValueContextMenu = (e: React.MouseEvent, value: string) => { + if (value && value !== '—' && value !== '[REDACTED]' && onContextMenu) { + onContextMenu(e, value) + } + } + return ( -
    +
    + {/* Resize Handle */} +
    +
    +
    + {/* Header with Chevron */}
    setIsCollapsed(!isCollapsed)} + onClick={onToggleCollapsed} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setIsCollapsed(!isCollapsed) + onToggleCollapsed() } }} role='button' tabIndex={0} - aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'} + aria-label={isAtMinHeight ? 'Expand connections' : 'Collapse connections'} > -
    Connections
    {/* Content - styled like ConnectionBlocks */} - {!isCollapsed && ( -
    - {connections.map((connection) => { - const blockConfig = getBlock(connection.blockType) - const Icon = blockConfig?.icon - const bgColor = blockConfig?.bgColor || '#6B7280' - const isExpanded = expandedBlocks.has(connection.blockId) - const hasFields = connection.fields.length > 0 - - return ( -
    - {/* Block header - styled like ConnectionItem */} +
    + {connections.map((connection) => { + const blockConfig = getBlock(connection.blockType) + const Icon = blockConfig?.icon + const bgColor = blockConfig?.bgColor || '#6B7280' + const isExpanded = expandedBlocks.has(connection.blockId) + const hasFields = connection.fields.length > 0 + + return ( +
    + {/* Block header - styled like ConnectionItem */} +
    hasFields && toggleBlock(connection.blockId)} + >
    hasFields && toggleBlock(connection.blockId)} + className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]' + style={{ background: bgColor }} > -
    - {Icon && ( - - )} -
    - - {connection.blockName} - - {hasFields && ( - )}
    + + {connection.blockName} + + {hasFields && ( + + )} +
    - {/* Fields - styled like FieldItem but showing resolved values */} - {isExpanded && hasFields && ( -
    -
    - {connection.fields.map((field) => ( -
    +
    + {connection.fields.map((field) => ( +
    handleValueContextMenu(e, field.value)} + > + - - {field.path} - - - {field.value} - -
    - ))} + {field.path} + + + {field.value} + +
    + ))} +
    + )} +
    + ) + })} + + {/* Workflow Variables */} + {workflowVars.length > 0 && ( +
    +
    setExpandedVariables(!expandedVariables)} + > +
    + V +
    + + Variables + + +
    + {expandedVariables && ( +
    +
    + {workflowVars.map((v) => ( +
    handleValueContextMenu(e, v.value)} + > + + {v.name} + + {v.value}
    + ))} +
    + )} +
    + )} + + {/* Environment Variables */} + {envVars.length > 0 && ( +
    +
    setExpandedEnvVars(!expandedEnvVars)} + > +
    + E +
    + + Environment Variables + + +
    + {expandedEnvVars && ( +
    +
    + {envVars.map((v) => ( +
    + + {v.name} + + {v.value} +
    + ))}
    - ) - })} -
    - )} + )} +
    + )} +
    ) } @@ -352,6 +555,165 @@ function IconComponent({ return } +/** + * Configuration for subflow types (loop and parallel) - matches use-subflow-editor.ts + */ +const SUBFLOW_CONFIG = { + loop: { + typeLabels: { + for: 'For Loop', + forEach: 'For Each', + while: 'While Loop', + doWhile: 'Do While Loop', + }, + maxIterations: 1000, + }, + parallel: { + typeLabels: { + count: 'Parallel Count', + collection: 'Parallel Each', + }, + maxIterations: 20, + }, +} as const + +interface SubflowConfigDisplayProps { + block: BlockState + loop?: Loop + parallel?: Parallel +} + +/** + * Display subflow (loop/parallel) configuration in preview mode. + * Matches the exact UI structure of SubflowEditor. + */ +function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayProps) { + const isLoop = block.type === 'loop' + const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel + + // Determine current type + const currentType = isLoop + ? loop?.loopType || (block.data?.loopType as string) || 'for' + : parallel?.parallelType || (block.data?.parallelType as string) || 'count' + + // Build type options for combobox - matches SubflowEditor + const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({ + value, + label, + })) + + // Determine mode + const isCountMode = currentType === 'for' || currentType === 'count' + const isConditionMode = currentType === 'while' || currentType === 'doWhile' + + // Get iterations value + const iterations = isLoop + ? (loop?.iterations ?? (block.data?.count as number) ?? 5) + : (parallel?.count ?? (block.data?.count as number) ?? 1) + + // Get collection/condition value + const getEditorValue = (): string => { + if (isConditionMode && isLoop) { + if (currentType === 'while') { + return loop?.whileCondition || (block.data?.whileCondition as string) || '' + } + return loop?.doWhileCondition || (block.data?.doWhileCondition as string) || '' + } + + if (isLoop) { + const items = loop?.forEachItems ?? block.data?.collection + return typeof items === 'string' ? items : JSON.stringify(items) || '' + } + + const distribution = parallel?.distribution ?? block.data?.collection + return typeof distribution === 'string' ? distribution : JSON.stringify(distribution) || '' + } + + const editorValue = getEditorValue() + + // Get label for configuration field - matches SubflowEditor exactly + const getConfigLabel = (): string => { + if (isCountMode) { + return `${isLoop ? 'Loop' : 'Parallel'} Iterations` + } + if (isConditionMode) { + return 'While Condition' + } + return `${isLoop ? 'Collection' : 'Parallel'} Items` + } + + return ( +
    + {/* Type Selection - matches SubflowEditor */} +
    + + {}} + disabled + placeholder='Select type...' + /> +
    + + {/* Dashed Line Separator - matches SubflowEditor */} +
    +
    +
    + + {/* Configuration - matches SubflowEditor */} +
    + + + {isCountMode ? ( +
    + {}} + disabled + className='mb-[4px]' + /> +
    + Enter a number between 1 and {config.maxIterations} +
    +
    + ) : ( +
    + + + + {isConditionMode ? ' < 10' : "['item1', 'item2', 'item3']"} + +
    + {editorValue || ( + + {isConditionMode ? ' < 10' : "['item1', 'item2', 'item3']"} + + )} +
    +
    +
    +
    + )} +
    +
    + ) +} + interface ExecutionData { input?: unknown output?: unknown @@ -359,6 +721,13 @@ interface ExecutionData { durationMs?: number } +interface WorkflowVariable { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown +} + interface BlockDetailsSidebarProps { block: BlockState executionData?: ExecutionData @@ -366,6 +735,12 @@ interface BlockDetailsSidebarProps { allBlockExecutions?: Record /** All workflow blocks for mapping block names to IDs */ workflowBlocks?: Record + /** Workflow variables for resolving variable references */ + workflowVariables?: Record + /** Loop configurations for subflow blocks */ + loops?: Record + /** Parallel configurations for subflow blocks */ + parallels?: Record /** When true, shows "Not Executed" badge if no executionData is provided */ isExecutionMode?: boolean /** Optional close handler - if not provided, no close button is shown */ @@ -380,6 +755,13 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(2)}s` } +/** Minimum height for the connections section (header only) */ +const MIN_CONNECTIONS_HEIGHT = 30 +/** Maximum height for the connections section */ +const MAX_CONNECTIONS_HEIGHT = 300 +/** Default height for the connections section */ +const DEFAULT_CONNECTIONS_HEIGHT = 150 + /** * Readonly sidebar panel showing block configuration using SubBlock components. */ @@ -388,12 +770,160 @@ function BlockDetailsSidebarContent({ executionData, allBlockExecutions, workflowBlocks, + workflowVariables, + loops, + parallels, isExecutionMode = false, onClose, }: BlockDetailsSidebarProps) { + // Convert Record to Array for iteration + const normalizedWorkflowVariables = useMemo(() => { + if (!workflowVariables) return [] + return Object.values(workflowVariables) + }, [workflowVariables]) + const blockConfig = getBlock(block.type) as BlockConfig | undefined const subBlockValues = block.subBlocks || {} + const contentRef = useRef(null) + const subBlocksRef = useRef(null) + + // Connections resize state + const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT) + const [isResizing, setIsResizing] = useState(false) + const startYRef = useRef(0) + const startHeightRef = useRef(0) + + const { + wrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const { + isOpen: isContextMenuOpen, + position: contextMenuPosition, + menuRef: contextMenuRef, + handleContextMenu, + closeMenu: closeContextMenu, + } = useContextMenu() + + const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false }) + + const openContextMenu = useCallback( + (e: React.MouseEvent, content: string, copyOnly: boolean) => { + setContextMenuData({ content, copyOnly }) + handleContextMenu(e) + }, + [handleContextMenu] + ) + + const handleExecutionContextMenu = useCallback( + (e: React.MouseEvent) => { + const parts: string[] = [] + if (executionData?.input) { + parts.push(`// Input\n${formatValueAsJson(executionData.input)}`) + } + if (executionData?.output) { + parts.push(`// Output\n${formatValueAsJson(executionData.output)}`) + } + if (parts.length > 0) { + openContextMenu(e, parts.join('\n\n'), false) + } + }, + [executionData, openContextMenu] + ) + + const handleSubblockContextMenu = useCallback( + (e: React.MouseEvent, config: SubBlockConfig) => { + if (config.password || config.type === 'oauth-input') return + + const valueObj = subBlockValues[config.id] + const value = + valueObj && typeof valueObj === 'object' && 'value' in valueObj + ? (valueObj as { value: unknown }).value + : valueObj + + if (value !== undefined && value !== null && value !== '') { + const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + openContextMenu(e, content, true) + } + }, + [subBlockValues, openContextMenu] + ) + + const handleCopy = useCallback(() => { + if (contextMenuData.content) { + navigator.clipboard.writeText(contextMenuData.content) + } + }, [contextMenuData.content]) + + /** + * Handles mouse down event on the resize handle to initiate resizing + */ + const handleConnectionsResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + setIsResizing(true) + startYRef.current = e.clientY + startHeightRef.current = connectionsHeight + }, + [connectionsHeight] + ) + + /** + * Toggle connections collapsed state + */ + const toggleConnectionsCollapsed = useCallback(() => { + setConnectionsHeight((prev) => + prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT + ) + }, []) + + /** + * Sets up resize event listeners during resize operations + */ + useEffect(() => { + if (!isResizing) return + + const handleMouseMove = (e: MouseEvent) => { + const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up + let newHeight = startHeightRef.current + deltaY + + // Clamp height between fixed min and max for stable behavior + newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight)) + setConnectionsHeight(newHeight) + } + + const handleMouseUp = () => { + setIsResizing(false) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.body.style.cursor = 'ns-resize' + document.body.style.userSelect = 'none' + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, [isResizing]) + + // Determine if connections are at minimum height (collapsed state) + const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5 + const blockNameToId = useMemo(() => { const map = new Map() if (workflowBlocks) { @@ -432,18 +962,20 @@ function BlockDetailsSidebarContent({ } }, [allBlockExecutions, workflowBlocks, blockNameToId]) - // Group resolved variables by source block for display + const extractedRefs = useMemo( + () => extractAllReferencesFromSubBlocks(subBlockValues), + [subBlockValues] + ) + const resolvedConnections = useMemo((): ResolvedConnection[] => { if (!allBlockExecutions || !workflowBlocks) return [] - const allRefs = extractAllReferencesFromSubBlocks(subBlockValues) const seen = new Set() const blockMap = new Map() - for (const ref of allRefs) { + for (const ref of extractedRefs.blockRefs) { if (seen.has(ref)) continue - // Parse reference: const inner = ref.slice(1, -1) const parts = inner.split('.') if (parts.length < 1) continue @@ -461,7 +993,6 @@ function BlockDetailsSidebarContent({ seen.add(ref) - // Get or create block entry if (!blockMap.has(blockId)) { blockMap.set(blockId, { blockId, @@ -480,12 +1011,105 @@ function BlockDetailsSidebarContent({ } return Array.from(blockMap.values()) - }, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference]) + }, [extractedRefs.blockRefs, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference]) + + const resolvedWorkflowVars = useMemo((): ResolvedVariable[] => { + return extractedRefs.workflowVars.map((ref) => { + const inner = ref.slice(1, -1) + const parts = inner.split('.') + const varName = parts.slice(1).join('.') + + let value = '—' + if (normalizedWorkflowVariables.length > 0) { + const normalizedVarName = normalizeName(varName) + const matchedVar = normalizedWorkflowVariables.find( + (v) => normalizeName(v.name) === normalizedVarName + ) + if (matchedVar !== undefined) { + value = formatInlineValue(matchedVar.value) + } + } + + return { ref, name: varName, value } + }) + }, [extractedRefs.workflowVars, normalizedWorkflowVariables]) + + const resolvedEnvVars = useMemo((): ResolvedVariable[] => { + return extractedRefs.envVars.map((ref) => { + const varName = ref.slice(2, -2) + return { ref, name: varName, value: '[REDACTED]' } + }) + }, [extractedRefs.envVars]) + + // Check if this is a subflow block (loop or parallel) + const isSubflow = block.type === 'loop' || block.type === 'parallel' + const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined + const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined + + // Handle subflow blocks + if (isSubflow) { + const isLoop = block.type === 'loop' + const SubflowIcon = isLoop ? RepeatIcon : SplitIcon + const subflowBgColor = isLoop ? '#2FB3FF' : '#FEE12B' + const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel') + + return ( +
    + {/* Header - styled like subflow header */} +
    +
    + +
    + + {subflowName} + + {onClose && ( + + )} +
    + + {/* Subflow Configuration */} +
    +
    +
    + {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} + + +
    +
    +
    +
    + ) + } if (!blockConfig) { return ( -
    -
    +
    +
    {block.name || 'Unknown Block'} @@ -515,9 +1139,9 @@ function BlockDetailsSidebarContent({ : 'gray' return ( -
    +
    {/* Header - styled like editor */} -
    +
    - {/* Scrollable content */} -
    - {/* Not Executed Banner - shown when in execution mode but block wasn't executed */} - {isExecutionMode && !executionData && ( -
    -
    - - Not Executed - -
    -
    - )} - - {/* Execution Input/Output (if provided) */} - {executionData && - (executionData.input !== undefined || executionData.output !== undefined) ? ( -
    - {/* Execution Status & Duration Header */} - {(executionData.status || executionData.durationMs !== undefined) && ( -
    - {executionData.status && ( - - {executionData.status} + {/* Content area */} +
    + {/* Subblocks Section */} +
    +
    + {/* Not Executed Banner - shown when in execution mode but block wasn't executed */} + {isExecutionMode && !executionData && ( +
    +
    + + Not Executed - )} - {executionData.durationMs !== undefined && ( - - {formatDuration(executionData.durationMs)} - - )} +
    )} - {/* Divider between Status/Duration and Input/Output */} - {(executionData.status || executionData.durationMs !== undefined) && - (executionData.input !== undefined || executionData.output !== undefined) && ( -
    - )} + {/* Execution Input/Output (if provided) */} + {executionData && + (executionData.input !== undefined || executionData.output !== undefined) ? ( +
    + {/* Execution Status & Duration Header */} + {(executionData.status || executionData.durationMs !== undefined) && ( +
    + {executionData.status && ( + + {executionData.status} + + )} + {executionData.durationMs !== undefined && ( + + {formatDuration(executionData.durationMs)} + + )} +
    + )} - {/* Input Section */} - {executionData.input !== undefined && ( - - )} + {/* Divider between Status/Duration and Input/Output */} + {(executionData.status || executionData.durationMs !== undefined) && + (executionData.input !== undefined || executionData.output !== undefined) && ( +
    + )} - {/* Divider between Input and Output */} - {executionData.input !== undefined && executionData.output !== undefined && ( -
    - )} + {/* Input Section */} + {executionData.input !== undefined && ( + + )} - {/* Output Section */} - {executionData.output !== undefined && ( - - )} -
    - ) : null} + {/* Divider between Input and Output */} + {executionData.input !== undefined && executionData.output !== undefined && ( +
    + )} - {/* Subblock Values - Using SubBlock components in preview mode */} -
    - {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} - - {visibleSubBlocks.length > 0 ? ( -
    - {visibleSubBlocks.map((subBlockConfig, index) => ( -
    - - {index < visibleSubBlocks.length - 1 && ( -
    -
    0 ? ( +
    + {visibleSubBlocks.map((subBlockConfig, index) => ( +
    handleSubblockContextMenu(e, subBlockConfig)} + > + + {index < visibleSubBlocks.length - 1 && ( +
    +
    +
    + )}
    - )} + ))}
    - ))} -
    - ) : ( -
    -

    - No configurable fields for this block. -

    + ) : ( +
    +

    + No configurable fields for this block. +

    +
    + )}
    - )} +
    + + {/* Connections Section - Only show when there are connections */} + {(resolvedConnections.length > 0 || + resolvedWorkflowVars.length > 0 || + resolvedEnvVars.length > 0) && ( + openContextMenu(e, value, true)} + height={connectionsHeight} + isResizing={isResizing} + isAtMinHeight={isConnectionsAtMinHeight} + onResizeMouseDown={handleConnectionsResizeMouseDown} + onToggleCollapsed={toggleConnectionsCollapsed} + /> + )}
    - {/* Resolved Variables Section - Pinned at bottom, outside scrollable area */} - {resolvedConnections.length > 0 && ( - + {/* Search Overlay */} + {isSearchActive && ( +
    e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
    )} + + {/* Context Menu */} +
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx index 423ad95032..5725ec2fb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx @@ -5,12 +5,19 @@ import { Handle, type NodeProps, Position } from 'reactflow' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { getBlock } from '@/blocks' +/** Execution status for blocks in preview mode */ +type ExecutionStatus = 'success' | 'error' | 'not-executed' + interface WorkflowPreviewBlockData { type: string name: string isTrigger?: boolean horizontalHandles?: boolean enabled?: boolean + /** Whether this block is selected in preview mode */ + isPreviewSelected?: boolean + /** Execution status for highlighting error/success states */ + executionStatus?: ExecutionStatus } /** @@ -21,18 +28,20 @@ interface WorkflowPreviewBlockData { * Used in template cards and other preview contexts for performance. */ function WorkflowPreviewBlockInner({ data }: NodeProps) { - const { type, name, isTrigger = false, horizontalHandles = false, enabled = true } = data + const { + type, + name, + isTrigger = false, + horizontalHandles = false, + enabled = true, + isPreviewSelected = false, + executionStatus, + } = data const blockConfig = getBlock(type) - if (!blockConfig) { - return null - } - - const IconComponent = blockConfig.icon - const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger const visibleSubBlocks = useMemo(() => { - if (!blockConfig.subBlocks) return [] + if (!blockConfig?.subBlocks) return [] return blockConfig.subBlocks.filter((subBlock) => { if (subBlock.hidden) return false @@ -41,7 +50,14 @@ function WorkflowPreviewBlockInner({ data }: NodeProps if (subBlock.mode === 'advanced') return false return true }) - }, [blockConfig.subBlocks]) + }, [blockConfig?.subBlocks]) + + if (!blockConfig) { + return null + } + + const IconComponent = blockConfig.icon + const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger const hasSubBlocks = visibleSubBlocks.length > 0 const showErrorRow = !isStarterOrTrigger @@ -49,8 +65,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]' const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]' + const hasError = executionStatus === 'error' + const hasSuccess = executionStatus === 'success' + return (
    + {/* Selection ring overlay (takes priority over execution rings) */} + {isPreviewSelected && ( +
    + )} + {/* Success ring overlay (only shown if not selected) */} + {!isPreviewSelected && hasSuccess && ( +
    + )} + {/* Error ring overlay (only shown if not selected) */} + {!isPreviewSelected && hasError && ( +
    + )} + {/* Target handle - not shown for triggers/starters */} {!isStarterOrTrigger && ( ) } -export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner) +function shouldSkipPreviewBlockRender( + prevProps: NodeProps, + nextProps: NodeProps +): boolean { + return ( + prevProps.id === nextProps.id && + prevProps.data.type === nextProps.data.type && + prevProps.data.name === nextProps.data.name && + prevProps.data.isTrigger === nextProps.data.isTrigger && + prevProps.data.horizontalHandles === nextProps.data.horizontalHandles && + prevProps.data.enabled === nextProps.data.enabled && + prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected && + prevProps.data.executionStatus === nextProps.data.executionStatus + ) +} + +export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx index 67befddbda..99a0e8ca9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx @@ -10,6 +10,8 @@ interface WorkflowPreviewSubflowData { width?: number height?: number kind: 'loop' | 'parallel' + /** Whether this subflow is selected in preview mode */ + isPreviewSelected?: boolean } /** @@ -19,7 +21,7 @@ interface WorkflowPreviewSubflowData { * Used in template cards and other preview contexts for performance. */ function WorkflowPreviewSubflowInner({ data }: NodeProps) { - const { name, width = 500, height = 300, kind } = data + const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data const isLoop = kind === 'loop' const BlockIcon = isLoop ? RepeatIcon : SplitIcon @@ -42,6 +44,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps + {/* Selection ring overlay */} + {isPreviewSelected && ( +
    + )} + {/* Target handle on left (input to the subflow) */} - {/* Header - matches actual subflow header */} -
    -
    - + {/* Header - matches actual subflow header structure */} +
    +
    +
    + +
    + + {blockName} +
    - - {blockName} -
    - {/* Start handle inside - connects to first block in subflow */} -
    - Start - + {/* Content area - matches workflow structure */} +
    + {/* Subflow Start - connects to first block in subflow */} +
    + Start + +
    {/* End source handle on right (output from the subflow) */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts index 4c959e26d3..89c096d6eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts @@ -1,2 +1,2 @@ export { BlockDetailsSidebar } from './components/block-details-sidebar' -export { WorkflowPreview } from './preview' +export { getLeftmostBlockId, WorkflowPreview } from './preview' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index b617d3da59..af554710cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import ReactFlow, { ConnectionLineType, type Edge, @@ -14,23 +14,114 @@ import 'reactflow/dist/style.css' import { createLogger } from '@sim/logger' import { cn } from '@/lib/core/utils/cn' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block' import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' +import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block' import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow' import { getBlock } from '@/blocks' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowPreview') +/** + * Gets block dimensions for preview purposes. + * For containers, uses stored dimensions or defaults. + * For regular blocks, uses stored height or estimates based on type. + */ +function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { + if (block.type === 'loop' || block.type === 'parallel') { + return { + width: block.data?.width + ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) + : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: block.data?.height + ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) + : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + if (block.height) { + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } + } + + return estimateBlockDimensions(block.type) +} + +/** + * Calculates container dimensions based on child block positions and sizes. + * Mirrors the logic from useNodeUtilities.calculateLoopDimensions. + */ +function calculateContainerDimensions( + containerId: string, + blocks: Record +): { width: number; height: number } { + const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId) + + if (childBlocks.length === 0) { + return { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + let maxRight = 0 + let maxBottom = 0 + + for (const child of childBlocks) { + if (!child?.position) continue + + const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child) + + maxRight = Math.max(maxRight, child.position.x + childWidth) + maxBottom = Math.max(maxBottom, child.position.y + childHeight) + } + + const width = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + ) + const height = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING + ) + + return { width, height } +} + +/** + * Finds the leftmost block ID from a workflow state. + * Returns the block with the smallest x position, excluding subflow containers (loop/parallel). + */ +export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null { + if (!workflowState?.blocks) return null + + let leftmostId: string | null = null + let minX = Number.POSITIVE_INFINITY + + for (const [blockId, block] of Object.entries(workflowState.blocks)) { + if (!block || block.type === 'loop' || block.type === 'parallel') continue + const x = block.position?.x ?? Number.POSITIVE_INFINITY + if (x < minX) { + minX = x + leftmostId = blockId + } + } + + return leftmostId +} + /** Execution status for edges/nodes in the preview */ type ExecutionStatus = 'success' | 'error' | 'not-executed' interface WorkflowPreviewProps { workflowState: WorkflowState - showSubBlocks?: boolean className?: string height?: string | number width?: string | number @@ -39,12 +130,18 @@ interface WorkflowPreviewProps { defaultZoom?: number fitPadding?: number onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void + /** Callback when a node is right-clicked */ + onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void + /** Callback when the canvas (empty area) is clicked */ + onPaneClick?: () => void /** Use lightweight blocks for better performance in template cards */ lightweight?: boolean /** Cursor style to show when hovering the canvas */ cursorStyle?: 'default' | 'pointer' | 'grab' /** Map of executed block IDs to their status for highlighting the execution path */ executedBlocks?: Record + /** Currently selected block ID for highlighting */ + selectedBlockId?: string | null } /** @@ -73,44 +170,49 @@ const edgeTypes: EdgeTypes = { } interface FitViewOnChangeProps { - nodes: Node[] + nodeIds: string fitPadding: number } /** - * Helper component that calls fitView when nodes change. + * Helper component that calls fitView when the set of nodes changes. + * Only triggers on actual node additions/removals, not on selection changes. * Must be rendered inside ReactFlowProvider. */ -function FitViewOnChange({ nodes, fitPadding }: FitViewOnChangeProps) { +function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) { const { fitView } = useReactFlow() + const hasFittedRef = useRef(false) useEffect(() => { - if (nodes.length > 0) { + 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) } - }, [nodes, fitPadding, fitView]) + }, [nodeIds, fitPadding, fitView]) return null } export function WorkflowPreview({ workflowState, - showSubBlocks = true, className, height = '100%', width = '100%', - isPannable = false, + isPannable = true, defaultPosition, defaultZoom = 0.8, fitPadding = 0.25, onNodeClick, + onNodeContextMenu, + onPaneClick, lightweight = false, cursorStyle = 'grab', executedBlocks, + selectedBlockId, }: WorkflowPreviewProps) { const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes const isValidWorkflowState = workflowState?.blocks && workflowState.edges @@ -184,6 +286,8 @@ export function WorkflowPreview({ if (lightweight) { if (block.type === 'loop' || block.type === 'parallel') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -191,31 +295,56 @@ export function WorkflowPreview({ draggable: false, data: { name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, kind: block.type as 'loop' | 'parallel', + isPreviewSelected: isSelected, }, }) return } + const isSelected = selectedBlockId === blockId + + let lightweightExecutionStatus: ExecutionStatus | undefined + if (executedBlocks) { + const blockExecution = executedBlocks[blockId] + if (blockExecution) { + if (blockExecution.status === 'error') { + lightweightExecutionStatus = 'error' + } else if (blockExecution.status === 'success') { + lightweightExecutionStatus = 'success' + } else { + lightweightExecutionStatus = 'not-executed' + } + } else { + lightweightExecutionStatus = 'not-executed' + } + } + nodeArray.push({ id: blockId, type: 'workflowBlock', position: absolutePosition, draggable: false, + // Blocks inside subflows need higher z-index to appear above the container + zIndex: block.data?.parentId ? 10 : undefined, data: { type: block.type, name: block.name, isTrigger: block.triggerMode === true, horizontalHandles: block.horizontalHandles ?? false, enabled: block.enabled ?? true, + isPreviewSelected: isSelected, + executionStatus: lightweightExecutionStatus, }, }) return } if (block.type === 'loop') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -226,10 +355,11 @@ export function WorkflowPreview({ data: { ...block.data, name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, state: 'valid', isPreview: true, + isPreviewSelected: isSelected, kind: 'loop', }, }) @@ -237,6 +367,8 @@ export function WorkflowPreview({ } if (block.type === 'parallel') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -247,10 +379,11 @@ export function WorkflowPreview({ data: { ...block.data, name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, state: 'valid', isPreview: true, + isPreviewSelected: isSelected, kind: 'parallel', }, }) @@ -281,15 +414,15 @@ export function WorkflowPreview({ } } + const isSelected = selectedBlockId === blockId + nodeArray.push({ id: blockId, type: nodeType, position: absolutePosition, draggable: false, - className: - executionStatus && executionStatus !== 'not-executed' - ? `execution-${executionStatus}` - : undefined, + // Blocks inside subflows need higher z-index to appear above the container + zIndex: block.data?.parentId ? 10 : undefined, data: { type: block.type, config: blockConfig, @@ -297,6 +430,7 @@ export function WorkflowPreview({ blockState: block, canEdit: false, isPreview: true, + isPreviewSelected: isSelected, subBlockValues: block.subBlocks ?? {}, executionStatus, }, @@ -308,11 +442,11 @@ export function WorkflowPreview({ blocksStructure, loopsStructure, parallelsStructure, - showSubBlocks, workflowState.blocks, isValidWorkflowState, lightweight, executedBlocks, + selectedBlockId, ]) const edges: Edge[] = useMemo(() => { @@ -325,9 +459,8 @@ export function WorkflowPreview({ const targetExecuted = executedBlocks[edge.target] if (sourceExecuted && targetExecuted) { - if (targetExecuted.status === 'error') { - executionStatus = 'error' - } else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') { + // Edge is success if source succeeded and target was executed (even if target errored) + if (sourceExecuted.status === 'success') { executionStatus = 'success' } else { executionStatus = 'not-executed' @@ -344,6 +477,8 @@ export function WorkflowPreview({ sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, data: executionStatus ? { executionStatus } : undefined, + // Raise executed edges above default edges + zIndex: executionStatus === 'success' ? 10 : 0, } }) }, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks]) @@ -368,20 +503,19 @@ export function WorkflowPreview({
    { + event.preventDefault() + event.stopPropagation() + onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY }) + } + : undefined + } + onPaneClick={onPaneClick} /> - +
    ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 63c6748519..050f2757cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -165,7 +165,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const { data: session } = useSession() const queryClient = useQueryClient() const { data: organizationsData } = useOrganizations() - const { data: subscriptionData } = useSubscriptionData() + const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() const activeOrganization = organizationsData?.activeOrganization diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index 77b7637bbd..f2a0d13f12 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -4,6 +4,7 @@ import JSZip from 'jszip' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkflow') @@ -122,17 +123,12 @@ export function useExportWorkflow({ continue } - // Fetch workflow variables + // Fetch workflow variables (API returns Record format directly) const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`) - let workflowVariables: any[] = [] + let workflowVariables: Record | undefined if (variablesResponse.ok) { const variablesData = await variablesResponse.json() - workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + workflowVariables = variablesData?.data } // Prepare export state diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts index 6856cc099b..1d25315a3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -2,8 +2,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { exportWorkspaceToZip, + type FolderExportData, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkspace') @@ -74,15 +76,10 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) } const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`) - let workflowVariables: any[] = [] + let workflowVariables: Record | undefined if (variablesResponse.ok) { const variablesData = await variablesResponse.json() - workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + workflowVariables = variablesData?.data } workflowsToExport.push({ @@ -101,15 +98,13 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) } } - const foldersToExport: Array<{ - id: string - name: string - parentId: string | null - }> = (foldersData.folders || []).map((folder: any) => ({ - id: folder.id, - name: folder.name, - parentId: folder.parentId, - })) + const foldersToExport: FolderExportData[] = (foldersData.folders || []).map( + (folder: FolderExportData) => ({ + id: folder.id, + name: folder.name, + parentId: folder.parentId, + }) + ) const zipBlob = await exportWorkspaceToZip( workspaceName, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index 00c46a00a3..d4f294e7f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -79,21 +79,36 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { body: JSON.stringify(workflowData), }) - // Save variables if any - if (workflowData.variables && workflowData.variables.length > 0) { - const variablesPayload = workflowData.variables.map((v: any) => ({ - id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(), - workflowId: newWorkflowId, - name: v.name, - type: v.type, - value: v.value, - })) - - await fetch(`/api/workflows/${newWorkflowId}/variables`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesPayload }), - }) + // Save variables if any (handle both legacy Array and current Record formats) + if (workflowData.variables) { + // Convert to Record format for API (handles backwards compatibility with old Array exports) + const variablesArray = Array.isArray(workflowData.variables) + ? workflowData.variables + : Object.values(workflowData.variables) + + if (variablesArray.length > 0) { + const variablesRecord: Record< + string, + { id: string; workflowId: string; name: string; type: string; value: unknown } + > = {} + + for (const v of variablesArray) { + const id = typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID() + variablesRecord[id] = { + id, + workflowId: newWorkflowId, + name: v.name, + type: v.type, + value: v.value, + } + } + + await fetch(`/api/workflows/${newWorkflowId}/variables`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables: variablesRecord }), + }) + } } logger.info(`Imported workflow: ${workflowName}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts index b71487734b..1ad051307b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts @@ -159,21 +159,36 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) continue } - // Save variables if any - if (workflowData.variables && workflowData.variables.length > 0) { - const variablesPayload = workflowData.variables.map((v: any) => ({ - id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(), - workflowId: newWorkflow.id, - name: v.name, - type: v.type, - value: v.value, - })) - - await fetch(`/api/workflows/${newWorkflow.id}/variables`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesPayload }), - }) + // Save variables if any (handle both legacy Array and current Record formats) + if (workflowData.variables) { + // Convert to Record format for API (handles backwards compatibility with old Array exports) + const variablesArray = Array.isArray(workflowData.variables) + ? workflowData.variables + : Object.values(workflowData.variables) + + if (variablesArray.length > 0) { + const variablesRecord: Record< + string, + { id: string; workflowId: string; name: string; type: string; value: unknown } + > = {} + + for (const v of variablesArray) { + const id = typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID() + variablesRecord[id] = { + id, + workflowId: newWorkflow.id, + name: v.name, + type: v.type, + value: v.value, + } + } + + await fetch(`/api/workflows/${newWorkflow.id}/variables`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables: variablesRecord }), + }) + } } logger.info(`Imported workflow: ${workflowName}`) diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 76ef355ee3..1e6f7bf424 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -112,7 +112,7 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: {brand.name} - {isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA} + {isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA}   diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts index 052a861988..efe146ac56 100644 --- a/apps/sim/executor/__test-utils__/executor-mocks.ts +++ b/apps/sim/executor/__test-utils__/executor-mocks.ts @@ -427,9 +427,7 @@ export const createWorkflowWithResponse = (): SerializedWorkflow => ({ input: 'json', }, outputs: { - response: { - input: 'json', - }, + response: { type: 'json', description: 'Input response' }, }, enabled: true, metadata: { id: 'starter', name: 'Starter Block' }, @@ -444,11 +442,9 @@ export const createWorkflowWithResponse = (): SerializedWorkflow => ({ headers: 'json', }, outputs: { - response: { - data: 'json', - status: 'number', - headers: 'json', - }, + data: { type: 'json', description: 'Response data' }, + status: { type: 'number', description: 'Response status' }, + headers: { type: 'json', description: 'Response headers' }, }, enabled: true, metadata: { id: 'response', name: 'Response Block' }, diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index f483bbfc78..7a5d06f405 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -1,3 +1,5 @@ +import type { LoopType, ParallelType } from '@/lib/workflows/types' + export enum BlockType { PARALLEL = 'parallel', LOOP = 'loop', @@ -40,12 +42,8 @@ export const METADATA_ONLY_BLOCK_TYPES = [ BlockType.NOTE, ] as const -export type LoopType = 'for' | 'forEach' | 'while' | 'doWhile' - export type SentinelType = 'start' | 'end' -export type ParallelType = 'collection' | 'count' - export const EDGE = { CONDITION_PREFIX: 'condition-', CONDITION_TRUE: 'condition-true', diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index b00cc0f6ea..d702a1b80f 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -366,12 +366,12 @@ export class RouterBlockHandler implements BlockHandler { let systemPrompt = '' if (isAgentBlockType(targetBlock.metadata?.id)) { + const paramsPrompt = targetBlock.config?.params?.systemPrompt + const inputsPrompt = targetBlock.inputs?.systemPrompt systemPrompt = - targetBlock.config?.params?.systemPrompt || targetBlock.inputs?.systemPrompt || '' - - if (!systemPrompt && targetBlock.inputs) { - systemPrompt = targetBlock.inputs.systemPrompt || '' - } + (typeof paramsPrompt === 'string' ? paramsPrompt : '') || + (typeof inputsPrompt === 'string' ? inputsPrompt : '') || + '' } return { diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 89ded91231..b0e40ef6c9 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -28,6 +28,8 @@ async function fetchSubscriptionData(includeOrg = false) { interface UseSubscriptionDataOptions { /** Include organization membership and role data */ includeOrg?: boolean + /** Whether to enable the query (defaults to true) */ + enabled?: boolean } /** @@ -35,13 +37,14 @@ interface UseSubscriptionDataOptions { * @param options - Optional configuration */ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { - const { includeOrg = false } = options + const { includeOrg = false, enabled = true } = options return useQuery({ queryKey: subscriptionKeys.user(includeOrg), queryFn: () => fetchSubscriptionData(includeOrg), staleTime: 30 * 1000, placeholderData: keepPreviousData, + enabled, }) } @@ -58,17 +61,25 @@ async function fetchUsageLimitData() { return response.json() } +interface UseUsageLimitDataOptions { + /** Whether to enable the query (defaults to true) */ + enabled?: boolean +} + /** * Hook to fetch usage limit metadata * Returns: currentLimit, minimumLimit, canEdit, plan, updatedAt * Use this for editing usage limits, not for displaying current usage */ -export function useUsageLimitData() { +export function useUsageLimitData(options: UseUsageLimitDataOptions = {}) { + const { enabled = true } = options + return useQuery({ queryKey: subscriptionKeys.usage(), queryFn: fetchUsageLimitData, staleTime: 30 * 1000, placeholderData: keepPreviousData, + enabled, }) } diff --git a/apps/sim/hooks/use-code-viewer.ts b/apps/sim/hooks/use-code-viewer.ts new file mode 100644 index 0000000000..52d0300970 --- /dev/null +++ b/apps/sim/hooks/use-code-viewer.ts @@ -0,0 +1,155 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +interface UseCodeViewerFeaturesOptions { + /** Reference to the content container for scroll-to-match functionality */ + contentRef?: React.RefObject + /** Initial wrap text state (ignored if externalWrapText is provided) */ + initialWrapText?: boolean + /** External wrap text state (e.g., from Zustand store) */ + externalWrapText?: boolean + /** External setter for wrap text (required if externalWrapText is provided) */ + onWrapTextChange?: (wrap: boolean) => void + /** Callback when escape is pressed (optional, for custom handling) */ + onEscape?: () => void +} + +interface UseCodeViewerFeaturesReturn { + wrapText: boolean + setWrapText: (wrap: boolean) => void + toggleWrapText: () => void + + isSearchActive: boolean + searchQuery: string + setSearchQuery: (query: string) => void + matchCount: number + currentMatchIndex: number + activateSearch: () => void + closeSearch: () => void + goToNextMatch: () => void + goToPreviousMatch: () => void + handleMatchCountChange: (count: number) => void + searchInputRef: React.RefObject +} + +/** + * Reusable hook for Code.Viewer features: search and wrap text functionality. + * Supports both internal state and external state (e.g., from Zustand) for wrapText. + */ +export function useCodeViewerFeatures( + options: UseCodeViewerFeaturesOptions = {} +): UseCodeViewerFeaturesReturn { + const { + contentRef, + initialWrapText = true, + externalWrapText, + onWrapTextChange, + onEscape, + } = options + + // Use external state if provided, otherwise use internal state + const [internalWrapText, setInternalWrapText] = useState(initialWrapText) + const wrapText = externalWrapText !== undefined ? externalWrapText : internalWrapText + const setWrapText = onWrapTextChange ?? setInternalWrapText + + const [isSearchActive, setIsSearchActive] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [matchCount, setMatchCount] = useState(0) + const [currentMatchIndex, setCurrentMatchIndex] = useState(0) + const searchInputRef = useRef(null) + + const toggleWrapText = useCallback(() => { + setWrapText(!wrapText) + }, [wrapText, setWrapText]) + + const activateSearch = useCallback(() => { + setIsSearchActive(true) + setTimeout(() => { + searchInputRef.current?.focus() + }, 0) + }, []) + + const closeSearch = useCallback(() => { + setIsSearchActive(false) + setSearchQuery('') + setMatchCount(0) + setCurrentMatchIndex(0) + }, []) + + const goToNextMatch = useCallback(() => { + if (matchCount === 0) return + setCurrentMatchIndex((prev) => (prev + 1) % matchCount) + }, [matchCount]) + + const goToPreviousMatch = useCallback(() => { + if (matchCount === 0) return + setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount) + }, [matchCount]) + + const handleMatchCountChange = useCallback((count: number) => { + setMatchCount(count) + setCurrentMatchIndex(0) + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isSearchActive) { + e.preventDefault() + closeSearch() + onEscape?.() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isSearchActive, closeSearch, onEscape]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isSearchActive) return + + const isSearchInputFocused = document.activeElement === searchInputRef.current + + if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) { + e.preventDefault() + if (e.shiftKey) { + goToPreviousMatch() + } else { + goToNextMatch() + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isSearchActive, matchCount, goToNextMatch, goToPreviousMatch]) + + useEffect(() => { + if (!isSearchActive || matchCount === 0 || !contentRef?.current) return + + const matchElements = contentRef.current.querySelectorAll('[data-search-match]') + const currentElement = matchElements[currentMatchIndex] + + if (currentElement) { + currentElement.scrollIntoView({ block: 'center' }) + } + }, [currentMatchIndex, isSearchActive, matchCount, contentRef]) + + return { + wrapText, + setWrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } +} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 4df0e00f40..ba6fda4e1c 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -6,6 +6,17 @@ import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { useUndoRedo } from '@/hooks/use-undo-redo' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' import { useNotificationStore } from '@/stores/notifications' import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store' import { usePanelEditorStore } from '@/stores/panel/editor/store' @@ -20,8 +31,6 @@ import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/wo const logger = createLogger('CollaborativeWorkflow') -const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] - export function useCollaborativeWorkflow() { const undoRedo = useUndoRedo() const isUndoRedoInProgress = useRef(false) @@ -33,7 +42,7 @@ export function useCollaborativeWorkflow() { const { blockId, before, after } = e.detail || {} if (!blockId || !before || !after) return if (isUndoRedoInProgress.current) return - undoRedo.recordMove(blockId, before, after) + undoRedo.recordBatchMoveBlocks([{ blockId, before, after }]) } const parentUpdateHandler = (e: any) => { @@ -197,9 +206,9 @@ export function useCollaborativeWorkflow() { isApplyingRemoteChange.current = true try { - if (target === 'block') { + if (target === OPERATION_TARGETS.BLOCK) { switch (operation) { - case 'update-position': { + case BLOCK_OPERATIONS.UPDATE_POSITION: { const blockId = payload.id if (!data.timestamp) { @@ -227,22 +236,22 @@ export function useCollaborativeWorkflow() { } break } - case 'update-name': + case BLOCK_OPERATIONS.UPDATE_NAME: workflowStore.updateBlockName(payload.id, payload.name) break - case 'toggle-enabled': + case BLOCK_OPERATIONS.TOGGLE_ENABLED: workflowStore.toggleBlockEnabled(payload.id) break - case 'update-parent': + case BLOCK_OPERATIONS.UPDATE_PARENT: workflowStore.updateParentId(payload.id, payload.parentId, payload.extent) break - case 'update-advanced-mode': + case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: workflowStore.setBlockAdvancedMode(payload.id, payload.advancedMode) break - case 'update-trigger-mode': + case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: workflowStore.setBlockTriggerMode(payload.id, payload.triggerMode) break - case 'toggle-handles': { + case BLOCK_OPERATIONS.TOGGLE_HANDLES: { const currentBlock = workflowStore.blocks[payload.id] if (currentBlock && currentBlock.horizontalHandles !== payload.horizontalHandles) { workflowStore.toggleBlockHandles(payload.id) @@ -250,9 +259,9 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'blocks') { + } else if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { - case 'batch-update-positions': { + case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: { const { updates } = payload if (Array.isArray(updates)) { updates.forEach(({ id, position }: { id: string; position: Position }) => { @@ -264,12 +273,12 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'edge') { + } else if (target === OPERATION_TARGETS.EDGE) { switch (operation) { - case 'add': + case EDGE_OPERATIONS.ADD: workflowStore.addEdge(payload as Edge) break - case 'remove': { + case EDGE_OPERATIONS.REMOVE: { workflowStore.removeEdge(payload.id) const updatedBlocks = useWorkflowStore.getState().blocks @@ -290,9 +299,44 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'subflow') { + } else if (target === OPERATION_TARGETS.EDGES) { + switch (operation) { + case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { + const { ids } = payload + if (Array.isArray(ids)) { + ids.forEach((id: string) => { + workflowStore.removeEdge(id) + }) + + const updatedBlocks = useWorkflowStore.getState().blocks + const updatedEdges = useWorkflowStore.getState().edges + const graph = { + blocksById: updatedBlocks, + edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])), + } + + const undoRedoStore = useUndoRedoStore.getState() + const stackKeys = Object.keys(undoRedoStore.stacks) + stackKeys.forEach((key) => { + const [wfId, uId] = key.split(':') + if (wfId === activeWorkflowId) { + undoRedoStore.pruneInvalidEntries(wfId, uId, graph) + } + }) + } + break + } + case EDGES_OPERATIONS.BATCH_ADD_EDGES: { + const { edges } = payload + if (Array.isArray(edges)) { + edges.forEach((edge: Edge) => workflowStore.addEdge(edge)) + } + break + } + } + } else if (target === OPERATION_TARGETS.SUBFLOW) { switch (operation) { - case 'update': + case SUBFLOW_OPERATIONS.UPDATE: // Handle subflow configuration updates (loop/parallel type changes, etc.) if (payload.type === 'loop') { const { config } = payload @@ -325,9 +369,9 @@ export function useCollaborativeWorkflow() { } break } - } else if (target === 'variable') { + } else if (target === OPERATION_TARGETS.VARIABLE) { switch (operation) { - case 'add': + case VARIABLE_OPERATIONS.ADD: variablesStore.addVariable( { workflowId: payload.workflowId, @@ -338,7 +382,7 @@ export function useCollaborativeWorkflow() { payload.id ) break - case 'variable-update': + case VARIABLE_OPERATIONS.UPDATE: if (payload.field === 'name') { variablesStore.updateVariable(payload.variableId, { name: payload.value }) } else if (payload.field === 'value') { @@ -347,13 +391,13 @@ export function useCollaborativeWorkflow() { variablesStore.updateVariable(payload.variableId, { type: payload.value }) } break - case 'remove': + case VARIABLE_OPERATIONS.REMOVE: variablesStore.deleteVariable(payload.variableId) break } - } else if (target === 'workflow') { + } else if (target === OPERATION_TARGETS.WORKFLOW) { switch (operation) { - case 'replace-state': + case WORKFLOW_OPERATIONS.REPLACE_STATE: if (payload.state) { logger.info('Received workflow state replacement from remote user', { userId, @@ -386,9 +430,9 @@ export function useCollaborativeWorkflow() { } } - if (target === 'blocks') { + if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { - case 'batch-add-blocks': { + case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { const { blocks, edges, @@ -456,7 +500,7 @@ export function useCollaborativeWorkflow() { logger.info('Successfully applied batch-add-blocks from remote user') break } - case 'batch-remove-blocks': { + case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { const { ids } = payload logger.info('Received batch-remove-blocks from remote user', { userId, @@ -722,7 +766,12 @@ export function useCollaborativeWorkflow() { ) const collaborativeBatchUpdatePositions = useCallback( - (updates: Array<{ id: string; position: Position }>) => { + ( + updates: Array<{ id: string; position: Position }>, + options?: { + previousPositions?: Map + } + ) => { if (!isInActiveRoom()) { logger.debug('Skipping batch position update - not in active workflow') return @@ -735,8 +784,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-update-positions', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, payload: { updates }, }, workflowId: activeWorkflowId || '', @@ -746,8 +795,31 @@ export function useCollaborativeWorkflow() { updates.forEach(({ id, position }) => { workflowStore.updateBlockPosition(id, position) }) + + if (options?.previousPositions && options.previousPositions.size > 0) { + const moves = updates + .filter((u) => options.previousPositions!.has(u.id)) + .map((u) => { + const prev = options.previousPositions!.get(u.id)! + const block = workflowStore.blocks[u.id] + return { + blockId: u.id, + before: prev, + after: { + x: u.position.x, + y: u.position.y, + parentId: block?.data?.parentId, + }, + } + }) + .filter((m) => m.before.x !== m.after.x || m.before.y !== m.after.y) + + if (moves.length > 0) { + undoRedo.recordBatchMoveBlocks(moves) + } + } }, - [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] ) const collaborativeUpdateBlockName = useCallback( @@ -781,65 +853,169 @@ export function useCollaborativeWorkflow() { return { success: false, error: `Block name "${trimmedName}" already exists` } } - executeQueuedOperation('update-name', 'block', { id, name: trimmedName }, () => { - const result = workflowStore.updateBlockName(id, trimmedName) + executeQueuedOperation( + BLOCK_OPERATIONS.UPDATE_NAME, + OPERATION_TARGETS.BLOCK, + { id, name: trimmedName }, + () => { + const result = workflowStore.updateBlockName(id, trimmedName) - if (result.success && result.changedSubblocks.length > 0) { - logger.info('Emitting cascaded subblock updates from block rename', { - blockId: id, - newName: trimmedName, - updateCount: result.changedSubblocks.length, - }) + if (result.success && result.changedSubblocks.length > 0) { + logger.info('Emitting cascaded subblock updates from block rename', { + blockId: id, + newName: trimmedName, + updateCount: result.changedSubblocks.length, + }) - result.changedSubblocks.forEach( - ({ - blockId, - subBlockId, - newValue, - }: { - blockId: string - subBlockId: string - newValue: any - }) => { - const operationId = crypto.randomUUID() - addToQueue({ - id: operationId, - operation: { - operation: 'subblock-update', - target: 'subblock', - payload: { blockId, subblockId: subBlockId, value: newValue }, - }, - workflowId: activeWorkflowId || '', - userId: session?.user?.id || 'unknown', - }) - } - ) + result.changedSubblocks.forEach( + ({ + blockId, + subBlockId, + newValue, + }: { + blockId: string + subBlockId: string + newValue: any + }) => { + const operationId = crypto.randomUUID() + addToQueue({ + id: operationId, + operation: { + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, + payload: { blockId, subblockId: subBlockId, value: newValue }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + } + ) + } } - }) + ) return { success: true } }, [executeQueuedOperation, workflowStore, addToQueue, activeWorkflowId, session?.user?.id] ) - const collaborativeToggleBlockEnabled = useCallback( - (id: string) => { - executeQueuedOperation('toggle-enabled', 'block', { id }, () => + const collaborativeBatchToggleBlockEnabled = useCallback( + (ids: string[]) => { + if (ids.length === 0) return + + const previousStates: Record = {} + const validIds: string[] = [] + + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.enabled + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { workflowStore.toggleBlockEnabled(id) - ) + } + + undoRedo.recordBatchToggleEnabled(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeUpdateParentId = useCallback( (id: string, parentId: string, extent: 'parent') => { - executeQueuedOperation('update-parent', 'block', { id, parentId, extent }, () => - workflowStore.updateParentId(id, parentId, extent) + executeQueuedOperation( + BLOCK_OPERATIONS.UPDATE_PARENT, + OPERATION_TARGETS.BLOCK, + { id, parentId, extent }, + () => workflowStore.updateParentId(id, parentId, extent) ) }, [executeQueuedOperation, workflowStore] ) + const collaborativeBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + newParentId: string | null + newPosition: { x: number; y: number } + affectedEdges: Edge[] + }> + ) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch update parent - not in active workflow') + return + } + + if (updates.length === 0) return + + const batchUpdates = updates.map((u) => { + const block = workflowStore.blocks[u.blockId] + const oldParentId = block?.data?.parentId + const oldPosition = block?.position || { x: 0, y: 0 } + + return { + blockId: u.blockId, + oldParentId, + newParentId: u.newParentId || undefined, + oldPosition, + newPosition: u.newPosition, + affectedEdges: u.affectedEdges, + } + }) + + for (const update of updates) { + if (update.affectedEdges.length > 0) { + update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) + } + workflowStore.updateBlockPosition(update.blockId, update.newPosition) + if (update.newParentId) { + workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') + } + } + + undoRedo.recordBatchUpdateParent(batchUpdates) + + const operationId = crypto.randomUUID() + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, + payload: { + updates: batchUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) + }, + [isInActiveRoom, workflowStore, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] + ) + const collaborativeToggleBlockAdvancedMode = useCallback( (id: string) => { const currentBlock = workflowStore.blocks[id] @@ -848,8 +1024,8 @@ export function useCollaborativeWorkflow() { const newAdvancedMode = !currentBlock.advancedMode executeQueuedOperation( - 'update-advanced-mode', - 'block', + BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + OPERATION_TARGETS.BLOCK, { id, advancedMode: newAdvancedMode }, () => workflowStore.toggleBlockAdvancedMode(id) ) @@ -879,8 +1055,8 @@ export function useCollaborativeWorkflow() { } executeQueuedOperation( - 'update-trigger-mode', - 'block', + BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, + OPERATION_TARGETS.BLOCK, { id, triggerMode: newTriggerMode }, () => workflowStore.toggleBlockTriggerMode(id) ) @@ -888,27 +1064,50 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) - const collaborativeToggleBlockHandles = useCallback( - (id: string) => { - const currentBlock = workflowStore.blocks[id] - if (!currentBlock) return + const collaborativeBatchToggleBlockHandles = useCallback( + (ids: string[]) => { + if (ids.length === 0) return - const newHorizontalHandles = !currentBlock.horizontalHandles + const previousStates: Record = {} + const validIds: string[] = [] - executeQueuedOperation( - 'toggle-handles', - 'block', - { id, horizontalHandles: newHorizontalHandles }, - () => workflowStore.toggleBlockHandles(id) - ) + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.horizontalHandles ?? false + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { + workflowStore.toggleBlockHandles(id) + } + + undoRedo.recordBatchToggleHandles(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeAddEdge = useCallback( (edge: Edge) => { - executeQueuedOperation('add', 'edge', edge, () => workflowStore.addEdge(edge)) - // Only record edge addition if it's not part of a parent update operation + executeQueuedOperation(EDGE_OPERATIONS.ADD, OPERATION_TARGETS.EDGE, edge, () => + workflowStore.addEdge(edge) + ) if (!skipEdgeRecording.current) { undoRedo.recordAddEdge(edge.id) } @@ -920,13 +1119,11 @@ export function useCollaborativeWorkflow() { (edgeId: string) => { const edge = workflowStore.edges.find((e) => e.id === edgeId) - // Skip if edge doesn't exist (already removed during cascade deletion) if (!edge) { logger.debug('Edge already removed, skipping operation', { edgeId }) return } - // Check if the edge's source and target blocks still exist const sourceExists = workflowStore.blocks[edge.source] const targetExists = workflowStore.blocks[edge.target] @@ -939,23 +1136,75 @@ export function useCollaborativeWorkflow() { return } - // Only record edge removal if it's not part of a parent update operation if (!skipEdgeRecording.current) { - undoRedo.recordRemoveEdge(edgeId, edge) + undoRedo.recordBatchRemoveEdges([edge]) } - executeQueuedOperation('remove', 'edge', { id: edgeId }, () => + executeQueuedOperation(EDGE_OPERATIONS.REMOVE, OPERATION_TARGETS.EDGE, { id: edgeId }, () => workflowStore.removeEdge(edgeId) ) }, [executeQueuedOperation, workflowStore, undoRedo] ) + const collaborativeBatchRemoveEdges = useCallback( + (edgeIds: string[], options?: { skipUndoRedo?: boolean }) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch remove edges - not in active workflow') + return false + } + + if (edgeIds.length === 0) return false + + const edgeSnapshots: Edge[] = [] + const validEdgeIds: string[] = [] + + for (const edgeId of edgeIds) { + const edge = workflowStore.edges.find((e) => e.id === edgeId) + if (edge) { + const sourceExists = workflowStore.blocks[edge.source] + const targetExists = workflowStore.blocks[edge.target] + if (sourceExists && targetExists) { + edgeSnapshots.push(edge) + validEdgeIds.push(edgeId) + } + } + } + + if (validEdgeIds.length === 0) { + logger.debug('No valid edges to remove') + return false + } + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { ids: validEdgeIds }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + validEdgeIds.forEach((id) => workflowStore.removeEdge(id)) + + if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { + undoRedo.recordBatchRemoveEdges(edgeSnapshots) + } + + logger.info('Batch removed edges', { count: validEdgeIds.length }) + return true + }, + [isInActiveRoom, workflowStore, addToQueue, activeWorkflowId, session, undoRedo] + ) + const collaborativeSetSubblockValue = useCallback( (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return - // Skip socket operations when viewing baseline diff if (isBaselineDiffView) { logger.debug('Skipping collaborative subblock update while viewing baseline diff') return @@ -971,28 +1220,23 @@ export function useCollaborativeWorkflow() { return } - // Generate operation ID for queue tracking const operationId = crypto.randomUUID() - // Get fresh activeWorkflowId from store to avoid stale closure const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - // Add to queue for retry mechanism addToQueue({ id: operationId, operation: { - operation: 'subblock-update', - target: 'subblock', + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: currentActiveWorkflowId || '', userId: session?.user?.id || 'unknown', }) - // Apply locally first (immediate UI feedback) subBlockStore.setValue(blockId, subblockId, value) - // Declarative clearing: clear sub-blocks that depend on this subblockId try { const visited = options?._visited || new Set() if (visited.has(subblockId)) return @@ -1004,9 +1248,7 @@ export function useCollaborativeWorkflow() { (sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId) ) for (const dep of dependents) { - // Skip clearing if the dependent is the same field if (!dep?.id || dep.id === subblockId) continue - // Cascade using the same collaborative path so it emits and further cascades collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited }) } } @@ -1049,8 +1291,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'subblock-update', - target: 'subblock', + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: activeWorkflowId || '', @@ -1096,12 +1338,17 @@ export function useCollaborativeWorkflow() { doWhileCondition: existingDoWhileCondition ?? '', } - executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () => { - workflowStore.updateLoopType(loopId, loopType) - workflowStore.setLoopForEachItems(loopId, existingForEachItems ?? '') - workflowStore.setLoopWhileCondition(loopId, existingWhileCondition ?? '') - workflowStore.setLoopDoWhileCondition(loopId, existingDoWhileCondition ?? '') - }) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: loopId, type: 'loop', config }, + () => { + workflowStore.updateLoopType(loopId, loopType) + workflowStore.setLoopForEachItems(loopId, existingForEachItems ?? '') + workflowStore.setLoopWhileCondition(loopId, existingWhileCondition ?? '') + workflowStore.setLoopDoWhileCondition(loopId, existingDoWhileCondition ?? '') + } + ) }, [executeQueuedOperation, workflowStore] ) @@ -1134,8 +1381,8 @@ export function useCollaborativeWorkflow() { } executeQueuedOperation( - 'update', - 'subflow', + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, { id: parallelId, type: 'parallel', config }, () => { workflowStore.updateParallelType(parallelId, parallelType) @@ -1169,8 +1416,11 @@ export function useCollaborativeWorkflow() { forEachItems: currentCollection, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () => - workflowStore.updateLoopCount(nodeId, count) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'loop', config }, + () => workflowStore.updateLoopCount(nodeId, count) ) } else { const currentDistribution = currentBlock.data?.collection || '' @@ -1184,8 +1434,11 @@ export function useCollaborativeWorkflow() { parallelType: currentParallelType, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () => - workflowStore.updateParallelCount(nodeId, count) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'parallel', config }, + () => workflowStore.updateParallelCount(nodeId, count) ) } }, @@ -1230,11 +1483,16 @@ export function useCollaborativeWorkflow() { doWhileCondition: nextDoWhileCondition ?? '', } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () => { - workflowStore.setLoopForEachItems(nodeId, nextForEachItems ?? '') - workflowStore.setLoopWhileCondition(nodeId, nextWhileCondition ?? '') - workflowStore.setLoopDoWhileCondition(nodeId, nextDoWhileCondition ?? '') - }) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'loop', config }, + () => { + workflowStore.setLoopForEachItems(nodeId, nextForEachItems ?? '') + workflowStore.setLoopWhileCondition(nodeId, nextWhileCondition ?? '') + workflowStore.setLoopDoWhileCondition(nodeId, nextDoWhileCondition ?? '') + } + ) } else { const currentCount = currentBlock.data?.count || 5 const currentParallelType = currentBlock.data?.parallelType || 'count' @@ -1247,8 +1505,11 @@ export function useCollaborativeWorkflow() { parallelType: currentParallelType, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () => - workflowStore.updateParallelCollection(nodeId, collection) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'parallel', config }, + () => workflowStore.updateParallelCollection(nodeId, collection) ) } }, @@ -1257,15 +1518,20 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateVariable = useCallback( (variableId: string, field: 'name' | 'value' | 'type', value: any) => { - executeQueuedOperation('variable-update', 'variable', { variableId, field, value }, () => { - if (field === 'name') { - variablesStore.updateVariable(variableId, { name: value }) - } else if (field === 'value') { - variablesStore.updateVariable(variableId, { value }) - } else if (field === 'type') { - variablesStore.updateVariable(variableId, { type: value }) + executeQueuedOperation( + VARIABLE_OPERATIONS.UPDATE, + OPERATION_TARGETS.VARIABLE, + { variableId, field, value }, + () => { + if (field === 'name') { + variablesStore.updateVariable(variableId, { name: value }) + } else if (field === 'value') { + variablesStore.updateVariable(variableId, { value }) + } else if (field === 'type') { + variablesStore.updateVariable(variableId, { type: value }) + } } - }) + ) }, [executeQueuedOperation, variablesStore] ) @@ -1287,7 +1553,12 @@ export function useCollaborativeWorkflow() { // Queue operation with processed name for server & other clients // Empty callback because local store is already updated above - executeQueuedOperation('add', 'variable', payloadWithProcessedName, () => {}) + executeQueuedOperation( + VARIABLE_OPERATIONS.ADD, + OPERATION_TARGETS.VARIABLE, + payloadWithProcessedName, + () => {} + ) } return id @@ -1299,9 +1570,14 @@ export function useCollaborativeWorkflow() { (variableId: string) => { cancelOperationsForVariable(variableId) - executeQueuedOperation('remove', 'variable', { variableId }, () => { - variablesStore.deleteVariable(variableId) - }) + executeQueuedOperation( + VARIABLE_OPERATIONS.REMOVE, + OPERATION_TARGETS.VARIABLE, + { variableId }, + () => { + variablesStore.deleteVariable(variableId) + } + ) }, [executeQueuedOperation, variablesStore, cancelOperationsForVariable] ) @@ -1337,8 +1613,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks, edges, loops, parallels, subBlockValues }, }, workflowId: activeWorkflowId || '', @@ -1469,8 +1745,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: Array.from(allBlocksToRemove) }, }, workflowId: activeWorkflowId || '', @@ -1512,15 +1788,17 @@ export function useCollaborativeWorkflow() { // Collaborative operations collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, - collaborativeToggleBlockEnabled, + collaborativeBatchToggleBlockEnabled, collaborativeUpdateParentId, + collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockHandles, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeAddEdge, collaborativeRemoveEdge, + collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, collaborativeSetTagSelection, diff --git a/apps/sim/hooks/use-forwarded-ref.ts b/apps/sim/hooks/use-forwarded-ref.ts deleted file mode 100644 index 70bbc4ad37..0000000000 --- a/apps/sim/hooks/use-forwarded-ref.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type MutableRefObject, useEffect, useRef } from 'react' - -/** - * A hook that handles forwarded refs and returns a mutable ref object - * Useful for components that need both a forwarded ref and a local ref - * @param forwardedRef The forwarded ref from React.forwardRef - * @returns A mutable ref object that can be used locally - */ -export function useForwardedRef( - forwardedRef: React.ForwardedRef -): MutableRefObject { - const innerRef = useRef(null) - - useEffect(() => { - if (!forwardedRef) return - - if (typeof forwardedRef === 'function') { - forwardedRef(innerRef.current) - } else { - forwardedRef.current = innerRef.current - } - }, [forwardedRef]) - - return innerRef -} diff --git a/apps/sim/hooks/use-subscription-state.ts b/apps/sim/hooks/use-subscription-state.ts deleted file mode 100644 index 5bb52ad135..0000000000 --- a/apps/sim/hooks/use-subscription-state.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' - -const logger = createLogger('useSubscriptionState') - -interface UsageData { - current: number - limit: number - percentUsed: number - isWarning: boolean - isExceeded: boolean - billingPeriodStart: Date | null - billingPeriodEnd: Date | null - lastPeriodCost: number -} - -interface SubscriptionState { - isPaid: boolean - isPro: boolean - isTeam: boolean - isEnterprise: boolean - plan: string - status: string | null - seats: number | null - metadata: any | null - usage: UsageData -} - -/** - * Consolidated hook for subscription state management - * Combines subscription status, features, and usage data - */ -export function useSubscriptionState() { - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchSubscriptionState = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - - const response = await fetch('/api/billing?context=user') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result = await response.json() - const subscriptionData = result.data - setData(subscriptionData) - } catch (error) { - const err = error instanceof Error ? error : new Error('Failed to fetch subscription state') - logger.error('Failed to fetch subscription state', { error }) - setError(err) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchSubscriptionState() - }, [fetchSubscriptionState]) - - const refetch = useCallback(() => { - return fetchSubscriptionState() - }, [fetchSubscriptionState]) - - return { - subscription: { - isPaid: data?.isPaid ?? false, - isPro: data?.isPro ?? false, - isTeam: data?.isTeam ?? false, - isEnterprise: data?.isEnterprise ?? false, - isFree: !(data?.isPaid ?? false), - plan: data?.plan ?? 'free', - status: data?.status, - seats: data?.seats, - metadata: data?.metadata, - }, - - usage: { - current: data?.usage?.current ?? 0, - limit: data?.usage?.limit ?? DEFAULT_FREE_CREDITS, - percentUsed: data?.usage?.percentUsed ?? 0, - isWarning: data?.usage?.isWarning ?? false, - isExceeded: data?.usage?.isExceeded ?? false, - billingPeriodStart: data?.usage?.billingPeriodStart - ? new Date(data.usage.billingPeriodStart) - : null, - billingPeriodEnd: data?.usage?.billingPeriodEnd - ? new Date(data.usage.billingPeriodEnd) - : null, - lastPeriodCost: data?.usage?.lastPeriodCost ?? 0, - }, - - isLoading, - error, - refetch, - - isAtLeastPro: () => { - return data?.isPro || data?.isTeam || data?.isEnterprise || false - }, - - isAtLeastTeam: () => { - return data?.isTeam || data?.isEnterprise || false - }, - - canUpgrade: () => { - return data?.plan === 'free' || data?.plan === 'pro' - }, - - getBillingStatus: () => { - const usage = data?.usage - if (!usage) return 'unknown' - - if (usage.isExceeded) return 'exceeded' - if (usage.isWarning) return 'warning' - return 'ok' - }, - - getRemainingBudget: () => { - const usage = data?.usage - if (!usage) return 0 - return Math.max(0, usage.limit - usage.current) - }, - - getDaysRemainingInPeriod: () => { - const usage = data?.usage - if (!usage?.billingPeriodEnd) return null - - const now = new Date() - const endDate = new Date(usage.billingPeriodEnd) - const diffTime = endDate.getTime() - now.getTime() - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) - - return Math.max(0, diffDays) - }, - } -} - -/** - * Hook for usage limit information with editing capabilities - */ -export function useUsageLimit() { - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchUsageLimit = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - - const response = await fetch('/api/usage?context=user') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const limitData = await response.json() - setData(limitData) - } catch (error) { - const err = error instanceof Error ? error : new Error('Failed to fetch usage limit') - logger.error('Failed to fetch usage limit', { error }) - setError(err) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchUsageLimit() - }, [fetchUsageLimit]) - - const refetch = useCallback(() => { - return fetchUsageLimit() - }, [fetchUsageLimit]) - - const updateLimit = async (newLimit: number) => { - try { - const response = await fetch('/api/usage?context=user', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ limit: newLimit }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update usage limit') - } - - await refetch() - - return { success: true } - } catch (error) { - logger.error('Failed to update usage limit', { error, newLimit }) - throw error - } - } - - return { - currentLimit: data?.currentLimit ?? DEFAULT_FREE_CREDITS, - canEdit: data?.canEdit ?? false, - minimumLimit: data?.minimumLimit ?? DEFAULT_FREE_CREDITS, - plan: data?.plan ?? 'free', - setBy: data?.setBy, - updatedAt: data?.updatedAt ? new Date(data.updatedAt) : null, - updateLimit, - isLoading, - error, - refetch, - } -} diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 33457cf390..740b50293b 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -3,14 +3,25 @@ import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { useSession } from '@/lib/auth/auth-client' import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + UNDO_REDO_OPERATIONS, +} from '@/socket/constants' import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, + type BatchAddEdgesOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, + type BatchToggleEnabledOperation, + type BatchToggleHandlesOperation, + type BatchUpdateParentOperation, createOperationEntry, - type MoveBlockOperation, - type Operation, - type RemoveEdgeOperation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, useUndoRedoStore, @@ -42,7 +53,7 @@ export function useUndoRedo() { const operation: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -55,7 +66,7 @@ export function useUndoRedo() { const inverse: BatchRemoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -88,7 +99,7 @@ export function useUndoRedo() { const operation: BatchRemoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -101,7 +112,7 @@ export function useUndoRedo() { const inverse: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -128,25 +139,28 @@ export function useUndoRedo() { (edgeId: string) => { if (!activeWorkflowId) return - const operation: Operation = { + const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId) + if (!edgeSnapshot) { + logger.warn('Edge not found when recording add edge', { edgeId }) + return + } + + const operation: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [edgeSnapshot] }, } - const inverse: RemoveEdgeOperation = { + const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - edgeId, - edgeSnapshot: workflowStore.edges.find((e) => e.id === edgeId) || null, - }, + data: { edgeSnapshots: [edgeSnapshot] }, } const entry = createOperationEntry(operation, inverse) @@ -157,77 +171,81 @@ export function useUndoRedo() { [activeWorkflowId, userId, workflowStore, undoRedoStore] ) - const recordRemoveEdge = useCallback( - (edgeId: string, edgeSnapshot: Edge) => { - if (!activeWorkflowId) return + const recordBatchRemoveEdges = useCallback( + (edgeSnapshots: Edge[]) => { + if (!activeWorkflowId || edgeSnapshots.length === 0) return - const operation: RemoveEdgeOperation = { + const operation: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - edgeId, - edgeSnapshot, + edgeSnapshots, }, } - const inverse: Operation = { + const inverse: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { + edgeSnapshots, + }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded remove edge', { edgeId, workflowId: activeWorkflowId }) + logger.debug('Recorded batch remove edges', { + edgeCount: edgeSnapshots.length, + workflowId: activeWorkflowId, + }) }, [activeWorkflowId, userId, undoRedoStore] ) - const recordMove = useCallback( + const recordBatchMoveBlocks = useCallback( ( - blockId: string, - before: { x: number; y: number; parentId?: string }, - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> ) => { - if (!activeWorkflowId) return + if (!activeWorkflowId || moves.length === 0) return - const operation: MoveBlockOperation = { + const operation: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - blockId, - before, - after, - }, + data: { moves }, } - const inverse: MoveBlockOperation = { + const inverse: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - blockId, - before: after, - after: before, + moves: moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded move', { blockId, from: before, to: after }) + logger.debug('Recorded batch move', { blockCount: moves.length }) }, [activeWorkflowId, userId, undoRedoStore] ) @@ -245,7 +263,7 @@ export function useUndoRedo() { const operation: UpdateParentOperation = { id: crypto.randomUUID(), - type: 'update-parent', + type: UNDO_REDO_OPERATIONS.UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -261,7 +279,7 @@ export function useUndoRedo() { const inverse: UpdateParentOperation = { id: crypto.randomUUID(), - type: 'update-parent', + type: UNDO_REDO_OPERATIONS.UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -271,7 +289,7 @@ export function useUndoRedo() { newParentId: oldParentId, oldPosition: newPosition, newPosition: oldPosition, - affectedEdges, // Same edges need to be restored + affectedEdges, }, } @@ -288,6 +306,117 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> + ) => { + if (!activeWorkflowId || updates.length === 0) return + + const operation: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { updates }, + } + + const inverse: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { + updates: updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch update parent', { + updateCount: updates.length, + workflowId: activeWorkflowId, + }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + + const recordBatchToggleEnabled = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle enabled', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + + const recordBatchToggleHandles = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES, + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle handles', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + const undo = useCallback(async () => { if (!activeWorkflowId) return @@ -307,7 +436,7 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.inverse.type) { - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const batchRemoveOp = entry.inverse as BatchRemoveBlocksOperation const { blockSnapshots } = batchRemoveOp.data const blockIds = blockSnapshots.map((b) => b.id) @@ -344,8 +473,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: existingBlockIds }, }, workflowId: activeWorkflowId, @@ -355,8 +484,9 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'batch-add-blocks': { - const batchAddOp = entry.operation as BatchAddBlocksOperation + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { + // Undoing a removal: inverse is batch-add-blocks, use entry.inverse for data + const batchAddOp = entry.inverse as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id]) @@ -368,8 +498,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks: blocksToAdd, edges: edgeSnapshots || [], @@ -422,127 +552,117 @@ export function useUndoRedo() { } break } - case 'remove-edge': { - const removeEdgeInverse = entry.inverse as RemoveEdgeOperation - const { edgeId } = removeEdgeInverse.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { + // Undo batch-add-edges: inverse is batch-remove-edges, so remove the edges + const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveInverse.data + + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { addToQueue({ id: opId, operation: { - operation: 'remove', - target: 'edge', - payload: { - id: edgeId, - isUndo: true, - originalOpId: entry.id, - }, + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { ids: edgesToRemove }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.removeEdge(edgeId) - } else { - logger.debug('Undo remove-edge skipped; edge missing', { - edgeId, - }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } + logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length }) break } - case 'add-edge': { - const originalOp = entry.operation as RemoveEdgeOperation - const { edgeSnapshot } = originalOp.data - // Skip if snapshot missing or already exists - if (!edgeSnapshot || workflowStore.edges.find((e) => e.id === edgeSnapshot.id)) { - logger.debug('Undo add-edge skipped', { - hasSnapshot: Boolean(edgeSnapshot), + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { + // Undo batch-remove-edges: inverse is batch-add-edges, so add edges back + const batchAddInverse = entry.inverse as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddInverse.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, }) - break + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...edgeSnapshot, isUndo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(edgeSnapshot) + logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) break } - case 'move-block': { - const moveOp = entry.inverse as MoveBlockOperation + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { + const batchMoveOp = entry.inverse as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { - // Apply the inverse's target as the undo result (inverse.after) + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isUndo: true, - originalOpId: entry.id, - }, + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Undo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } - case 'update-parent': { - // Undo parent update means reverting to the old parent and position + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { const updateOp = entry.inverse as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data if (workflowStore.blocks[blockId]) { - // If we're moving back INTO a subflow, restore edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isUndo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } - // Send position update to server addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'update-position', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_POSITION, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, position: newPosition, @@ -562,8 +682,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'update-parent', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, parentId: newParentId || '', @@ -587,8 +707,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'remove', - target: 'edge', + operation: EDGE_OPERATIONS.REMOVE, + target: OPERATION_TARGETS.EDGE, payload: { id: edge.id, isUndo: true }, }, workflowId: activeWorkflowId, @@ -602,8 +722,142 @@ export function useUndoRedo() { } break } - case 'apply-diff': { - // Undo apply-diff means clearing the diff and restoring baseline + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { + const batchUpdateOp = entry.inverse as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Undo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving OUT of subflow (undoing insert) → restore edges first + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + + // Moving INTO subflow (undoing removal) → remove edges first + if (newParentId && affectedEdges && affectedEdges.length > 0) { + affectedEdges.forEach((edge) => { + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) + } + }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Undid batch-update-parent', { updateCount: validUpdates.length }) + break + } + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: { + const toggleOp = entry.inverse as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + // Use setBlockEnabled to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios + validBlockIds.forEach((blockId) => { + workflowStore.setBlockEnabled(blockId, previousStates[blockId]) + }) + break + } + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: { + const toggleOp = entry.inverse as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + // Use setBlockHandles to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios + validBlockIds.forEach((blockId) => { + workflowStore.setBlockHandles(blockId, previousStates[blockId]) + }) + break + } + case UNDO_REDO_OPERATIONS.APPLY_DIFF: { const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -662,12 +916,11 @@ export function useUndoRedo() { logger.info('Undid apply-diff operation successfully') break } - case 'accept-diff': { + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: { // Undo accept-diff means restoring diff view with markers const acceptDiffInverse = entry.inverse as any const acceptDiffOp = entry.operation as any const { beforeAccept, diffAnalysis } = acceptDiffInverse.data - const { baselineSnapshot } = acceptDiffOp.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const diffStore = useWorkflowDiffStore.getState() @@ -722,10 +975,9 @@ export function useUndoRedo() { logger.info('Undid accept-diff operation - restored diff view') break } - case 'reject-diff': { + case UNDO_REDO_OPERATIONS.REJECT_DIFF: { // Undo reject-diff means restoring diff view with markers const rejectDiffInverse = entry.inverse as any - const rejectDiffOp = entry.operation as any const { beforeReject, diffAnalysis, baselineSnapshot } = rejectDiffInverse.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') @@ -793,7 +1045,7 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.operation.type) { - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const batchOp = entry.operation as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchOp.data @@ -806,8 +1058,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks: blocksToAdd, edges: edgeSnapshots || [], @@ -860,7 +1112,7 @@ export function useUndoRedo() { } break } - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const batchOp = entry.operation as BatchRemoveBlocksOperation const { blockSnapshots } = batchOp.data const blockIds = blockSnapshots.map((b) => b.id) @@ -874,8 +1126,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: existingBlockIds }, }, workflowId: activeWorkflowId, @@ -885,89 +1137,91 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'add-edge': { - // Use snapshot from inverse - const inv = entry.inverse as RemoveEdgeOperation - const snap = inv.data.edgeSnapshot - if (!snap || workflowStore.edges.find((e) => e.id === snap.id)) { - logger.debug('Redo add-edge skipped', { hasSnapshot: Boolean(snap) }) - break + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { + // Redo batch-remove-edges: remove all edges again + const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveOp.data + + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { ids: edgesToRemove }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...snap, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(snap) + + logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } - case 'remove-edge': { - const { edgeId } = entry.operation.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { + // Redo batch-add-edges: add all edges again + const batchAddOp = entry.operation as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddOp.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { addToQueue({ id: opId, operation: { - operation: 'remove', - target: 'edge', - payload: { id: edgeId, isRedo: true, originalOpId: entry.id }, + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.removeEdge(edgeId) - } else { - logger.debug('Redo remove-edge skipped; edge missing', { - edgeId, - }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } + + logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length }) break } - case 'move-block': { - const moveOp = entry.operation as MoveBlockOperation + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { + const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isRedo: true, - originalOpId: entry.id, - }, + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Redo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } - case 'update-parent': { + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { // Redo parent update means applying the new parent and position const updateOp = entry.operation as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data @@ -981,8 +1235,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'remove', - target: 'edge', + operation: EDGE_OPERATIONS.REMOVE, + target: OPERATION_TARGETS.EDGE, payload: { id: edge.id, isRedo: true }, }, workflowId: activeWorkflowId, @@ -996,8 +1250,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'update-position', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_POSITION, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, position: newPosition, @@ -1017,8 +1271,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'update-parent', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, parentId: newParentId || '', @@ -1035,29 +1289,165 @@ export function useUndoRedo() { workflowStore.updateParentId(blockId, newParentId || '', 'parent') // If we're adding TO a subflow, restore edges after + if (newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + } else { + logger.debug('Redo update-parent skipped; block missing', { blockId }) + } + break + } + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { + const batchUpdateOp = entry.operation as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Redo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving INTO subflow (redoing insert) → remove edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isRedo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) } }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + + // Moving OUT of subflow (redoing removal) → restore edges after + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } - } else { - logger.debug('Redo update-parent skipped; block missing', { blockId }) } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Redid batch-update-parent', { updateCount: validUpdates.length }) + break + } + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: { + const toggleOp = entry.operation as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + // Use setBlockEnabled to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) + validBlockIds.forEach((blockId) => { + workflowStore.setBlockEnabled(blockId, !previousStates[blockId]) + }) + break + } + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: { + const toggleOp = entry.operation as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + // Use setBlockHandles to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) + validBlockIds.forEach((blockId) => { + workflowStore.setBlockHandles(blockId, !previousStates[blockId]) + }) break } - case 'apply-diff': { + case UNDO_REDO_OPERATIONS.APPLY_DIFF: { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any const { proposedState, diffAnalysis, baselineSnapshot } = applyDiffOp.data @@ -1114,7 +1504,7 @@ export function useUndoRedo() { logger.info('Redid apply-diff operation') break } - case 'accept-diff': { + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: { // Redo accept-diff means re-accepting (stripping markers) const acceptDiffOp = entry.operation as any const { afterAccept } = acceptDiffOp.data @@ -1168,7 +1558,7 @@ export function useUndoRedo() { logger.info('Redid accept-diff operation - cleared diff view') break } - case 'reject-diff': { + case UNDO_REDO_OPERATIONS.REJECT_DIFF: { // Redo reject-diff means re-rejecting (restoring baseline, clearing diff) const rejectDiffOp = entry.operation as any const { afterReject } = rejectDiffOp.data @@ -1246,7 +1636,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'apply-diff', + type: UNDO_REDO_OPERATIONS.APPLY_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1259,7 +1649,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'apply-diff', + type: UNDO_REDO_OPERATIONS.APPLY_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1290,7 +1680,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'accept-diff', + type: UNDO_REDO_OPERATIONS.ACCEPT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1304,7 +1694,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'accept-diff', + type: UNDO_REDO_OPERATIONS.ACCEPT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1330,7 +1720,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'reject-diff', + type: UNDO_REDO_OPERATIONS.REJECT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1344,7 +1734,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'reject-diff', + type: UNDO_REDO_OPERATIONS.REJECT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1372,9 +1762,12 @@ export function useUndoRedo() { recordBatchAddBlocks, recordBatchRemoveBlocks, recordAddEdge, - recordRemoveEdge, - recordMove, + recordBatchRemoveEdges, + recordBatchMoveBlocks, recordUpdateParent, + recordBatchUpdateParent, + recordBatchToggleEnabled, + recordBatchToggleHandles, recordApplyDiff, recordAcceptDiff, recordRejectDiff, diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index e71a0cedb3..3df45eee07 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -302,7 +302,11 @@ export function useWebhookManagement({ effectiveTriggerId: string | undefined, selectedCredentialId: string | null ): Promise => { - const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const triggerConfigRaw = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const triggerConfig = + typeof triggerConfigRaw === 'object' && triggerConfigRaw !== null + ? (triggerConfigRaw as Record) + : {} const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX) const credentialSetId = isCredentialSet diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 5d5e5f8eb3..be7e2d5fc5 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -1,3 +1,5 @@ +import { db, workflow } from '@sim/db' +import { eq } from 'drizzle-orm' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import type { ExecutionEnvironment, ExecutionTrigger, WorkflowState } from '@/lib/logs/types' import { @@ -34,7 +36,15 @@ export function createEnvironmentObject( } export async function loadWorkflowStateForExecution(workflowId: string): Promise { - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const [normalizedData, workflowRecord] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId), + db + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + .then((rows) => rows[0]), + ]) if (!normalizedData) { throw new Error( @@ -47,6 +57,7 @@ export async function loadWorkflowStateForExecution(workflowId: string): Promise edges: normalizedData.edges || [], loops: normalizedData.loops || {}, parallels: normalizedData.parallels || {}, + variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined, } } @@ -65,6 +76,7 @@ export async function loadDeployedWorkflowStateForLogging( edges: deployedData.edges || [], loops: deployedData.loops || {}, parallels: deployedData.parallels || {}, + variables: deployedData.variables as WorkflowState['variables'], } } diff --git a/apps/sim/lib/logs/execution/snapshot/service.test.ts b/apps/sim/lib/logs/execution/snapshot/service.test.ts index 091bdb4a1b..543a2b1a16 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.test.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.test.ts @@ -105,7 +105,7 @@ describe('SnapshotService', () => { block1: { ...baseState.blocks.block1, // Different block state - we can change outputs to make it different - outputs: { response: { content: 'different result' } as Record }, + outputs: { response: { type: 'string', description: 'different result' } }, }, }, } @@ -177,7 +177,7 @@ describe('SnapshotService', () => { }, }, outputs: { - response: { content: 'Agent response' } as Record, + response: { type: 'string', description: 'Agent response' }, }, enabled: true, horizontalHandles: true, @@ -211,5 +211,113 @@ describe('SnapshotService', () => { const hash2 = service.computeStateHash(complexState) expect(hash).toBe(hash2) }) + + test('should include variables in hash computation', () => { + const stateWithVariables: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'apiKey', + type: 'string', + value: 'secret123', + }, + }, + } + + const stateWithoutVariables: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + } + + const hashWith = service.computeStateHash(stateWithVariables) + const hashWithout = service.computeStateHash(stateWithoutVariables) + + expect(hashWith).not.toBe(hashWithout) + }) + + test('should detect changes in variable values', () => { + const state1: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'myVar', + type: 'string', + value: 'value1', + }, + }, + } + + const state2: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'myVar', + type: 'string', + value: 'value2', // Different value + }, + }, + } + + const hash1 = service.computeStateHash(state1) + const hash2 = service.computeStateHash(state2) + + expect(hash1).not.toBe(hash2) + }) + + test('should generate consistent hashes for states with variables', () => { + const stateWithVariables: WorkflowState = { + blocks: { + block1: { + id: 'block1', + name: 'Test', + type: 'agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + advancedMode: false, + height: 0, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'testVar', + type: 'plain', + value: 'testValue', + }, + 'var-2': { + id: 'var-2', + name: 'anotherVar', + type: 'number', + value: 42, + }, + }, + } + + const hash1 = service.computeStateHash(stateWithVariables) + const hash2 = service.computeStateHash(stateWithVariables) + + expect(hash1).toBe(hash2) + expect(hash1).toHaveLength(64) + }) }) }) diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index b28e94e529..d753cbbd87 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -182,11 +182,15 @@ export class SnapshotService implements ISnapshotService { normalizedParallels[parallelId] = normalizeValue(parallel) } + // 4. Normalize variables (if present) + const normalizedVariables = state.variables ? normalizeValue(state.variables) : undefined + return { blocks: normalizedBlocks, edges: normalizedEdges, loops: normalizedLoops, parallels: normalizedParallels, + ...(normalizedVariables !== undefined && { variables: normalizedVariables }), } } } diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index c6055a713b..447eeefc6f 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema' const logger = createLogger('WorkflowMcpSync') @@ -59,7 +60,7 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise ({ }), })) +vi.mock('dns', () => ({ + resolveMx: ( + _domain: string, + callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void + ) => { + callback(null, [{ exchange: 'mail.example.com', priority: 10 }]) + }, +})) + describe('Email Validation', () => { describe('validateEmail', () => { it.concurrent('should validate a correct email', async () => { diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts index a20c35715a..7f8cf78190 100644 --- a/apps/sim/lib/workflows/autolayout/types.ts +++ b/apps/sim/lib/workflows/autolayout/types.ts @@ -1,5 +1,8 @@ import type { BlockState, Position } from '@/stores/workflows/workflow/types' +export type { Edge } from 'reactflow' +export type { Loop, Parallel } from '@/stores/workflows/workflow/types' + export interface LayoutOptions { horizontalSpacing?: number verticalSpacing?: number @@ -12,30 +15,6 @@ export interface LayoutResult { error?: string } -export interface Edge { - id: string - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null -} - -export interface Loop { - id: string - nodes: string[] - iterations: number - loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record | string // Items or expression - whileCondition?: string // JS expression that evaluates to boolean -} - -export interface Parallel { - id: string - nodes: string[] - count?: number - parallelType?: 'count' | 'collection' -} - export interface BlockMetrics { width: number height: number diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 6ea6eef84f..2fabf9692f 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -11,10 +11,23 @@ import { USER_FILE_PROPERTY_TYPES, } from '@/lib/workflows/types' import { getBlock } from '@/blocks' -import type { BlockConfig, OutputCondition } from '@/blocks/types' +import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types' import { getTrigger, isTriggerValid } from '@/triggers' -type OutputDefinition = Record +type OutputDefinition = Record + +interface SubBlockWithValue { + value?: unknown +} + +type ConditionValue = string | number | boolean + +/** + * Checks if a value is a valid primitive for condition comparison. + */ +function isConditionPrimitive(value: unknown): value is ConditionValue { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} /** * Evaluates an output condition against subBlock values. @@ -22,7 +35,7 @@ type OutputDefinition = Record */ function evaluateOutputCondition( condition: OutputCondition, - subBlocks: Record | undefined + subBlocks: Record | undefined ): boolean { if (!subBlocks) return false @@ -30,7 +43,8 @@ function evaluateOutputCondition( let matches: boolean if (Array.isArray(condition.value)) { - matches = condition.value.includes(fieldValue) + // For array conditions, check if fieldValue is a valid primitive and included + matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue) } else { matches = fieldValue === condition.value } @@ -44,7 +58,8 @@ function evaluateOutputCondition( let andMatches: boolean if (Array.isArray(condition.and.value)) { - andMatches = condition.and.value.includes(andFieldValue) + andMatches = + isConditionPrimitive(andFieldValue) && condition.and.value.includes(andFieldValue) } else { andMatches = andFieldValue === condition.and.value } @@ -65,7 +80,7 @@ function evaluateOutputCondition( */ function filterOutputsByCondition( outputs: OutputDefinition, - subBlocks: Record | undefined + subBlocks: Record | undefined ): OutputDefinition { const filtered: OutputDefinition = {} @@ -119,7 +134,7 @@ function hasInputFormat(blockConfig: BlockConfig): boolean { } function getTriggerId( - subBlocks: Record | undefined, + subBlocks: Record | undefined, blockConfig: BlockConfig ): string | undefined { const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value @@ -136,13 +151,17 @@ function getTriggerId( ) } -function getUnifiedStartOutputs(subBlocks: Record | undefined): OutputDefinition { +function getUnifiedStartOutputs( + subBlocks: Record | undefined +): OutputDefinition { const outputs = { ...UNIFIED_START_OUTPUTS } const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) return applyInputFormatFields(normalizedInputFormat, outputs) } -function getLegacyStarterOutputs(subBlocks: Record | undefined): OutputDefinition { +function getLegacyStarterOutputs( + subBlocks: Record | undefined +): OutputDefinition { const startWorkflowValue = subBlocks?.startWorkflow?.value if (startWorkflowValue === 'chat') { @@ -179,7 +198,7 @@ function shouldClearBaseOutputs( function applyInputFormatToOutputs( blockType: string, blockConfig: BlockConfig, - subBlocks: Record | undefined, + subBlocks: Record | undefined, baseOutputs: OutputDefinition ): OutputDefinition { if (!hasInputFormat(blockConfig) || !subBlocks?.inputFormat?.value) { @@ -203,7 +222,7 @@ function applyInputFormatToOutputs( export function getBlockOutputs( blockType: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): OutputDefinition { const blockConfig = getBlock(blockType) @@ -214,7 +233,8 @@ export function getBlockOutputs( if (triggerId && isTriggerValid(triggerId)) { const trigger = getTrigger(triggerId) if (trigger.outputs) { - return trigger.outputs + // TriggerOutput is compatible with OutputFieldDefinition at runtime + return trigger.outputs as OutputDefinition } } } @@ -226,7 +246,7 @@ export function getBlockOutputs( } if (blockType === 'human_in_the_loop') { - const hitlOutputs: Record = { + const hitlOutputs: OutputDefinition = { url: { type: 'string', description: 'Resume UI URL' }, resumeEndpoint: { type: 'string', @@ -251,7 +271,7 @@ export function getBlockOutputs( if (blockType === 'approval') { // Start with only url (apiUrl commented out - not accessible as output) - const pauseResumeOutputs: Record = { + const pauseResumeOutputs: OutputDefinition = { url: { type: 'string', description: 'Resume UI URL' }, // apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output } @@ -285,7 +305,7 @@ function shouldFilterReservedField( blockType: string, key: string, prefix: string, - subBlocks: Record | undefined + subBlocks: Record | undefined ): boolean { if (blockType !== TRIGGER_TYPES.START || prefix) { return false @@ -308,7 +328,7 @@ function expandFileTypeProperties(path: string): string[] { function collectOutputPaths( obj: OutputDefinition, blockType: string, - subBlocks: Record | undefined, + subBlocks: Record | undefined, prefix = '' ): string[] { const paths: string[] = [] @@ -321,13 +341,14 @@ function collectOutputPaths( } if (value && typeof value === 'object' && 'type' in value) { - if (value.type === 'files') { + const typedValue = value as { type: unknown } + if (typedValue.type === 'files') { paths.push(...expandFileTypeProperties(path)) } else { paths.push(path) } } else if (value && typeof value === 'object' && !Array.isArray(value)) { - paths.push(...collectOutputPaths(value, blockType, subBlocks, path)) + paths.push(...collectOutputPaths(value as OutputDefinition, blockType, subBlocks, path)) } else { paths.push(path) } @@ -338,7 +359,7 @@ function collectOutputPaths( export function getBlockOutputPaths( blockType: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): string[] { const outputs = getBlockOutputs(blockType, subBlocks, triggerMode) @@ -351,39 +372,45 @@ function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): st return null } - let current: any = outputs + let current: unknown = outputs for (const part of pathParts.slice(0, -1)) { if (!current || typeof current !== 'object') { return null } - current = current[part] + current = (current as Record)[part] } - if (current && typeof current === 'object' && 'type' in current && current.type === 'files') { + if ( + current && + typeof current === 'object' && + 'type' in current && + (current as { type: unknown }).type === 'files' + ) { return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES] } return null } -function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): any { - let current: any = outputs +function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unknown { + let current: unknown = outputs for (const part of pathParts) { if (!current || typeof current !== 'object') { return null } - current = current[part] + current = (current as Record)[part] } return current } -function extractType(value: any): string { +function extractType(value: unknown): string { if (!value) return 'any' if (typeof value === 'object' && 'type' in value) { - return value.type + const typeValue = (value as { type: unknown }).type + return typeof typeValue === 'string' ? typeValue : 'any' } return typeof value === 'string' ? value : 'any' @@ -392,7 +419,7 @@ function extractType(value: any): string { export function getBlockOutputType( blockType: string, outputPath: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): string { const outputs = getBlockOutputs(blockType, subBlocks, triggerMode) diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index a34521e23b..4f038cd8c2 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -51,8 +51,8 @@ export function hasWorkflowChanged( } // 3. Build normalized representations of blocks for comparison - const normalizedCurrentBlocks: Record = {} - const normalizedDeployedBlocks: Record = {} + const normalizedCurrentBlocks: Record = {} + const normalizedDeployedBlocks: Record = {} for (const blockId of currentBlockIds) { const currentBlock = currentState.blocks[blockId] @@ -120,8 +120,9 @@ export function hasWorkflowChanged( } // Get values with special handling for null/undefined - let currentValue = currentSubBlocks[subBlockId].value ?? null - let deployedValue = deployedSubBlocks[subBlockId].value ?? null + // Using unknown type since sanitization functions return different types + let currentValue: unknown = currentSubBlocks[subBlockId].value ?? null + let deployedValue: unknown = deployedSubBlocks[subBlockId].value ?? null if (subBlockId === 'tools' && Array.isArray(currentValue) && Array.isArray(deployedValue)) { currentValue = sanitizeTools(currentValue) @@ -232,8 +233,8 @@ export function hasWorkflowChanged( } // 6. Compare variables - const currentVariables = normalizeVariables((currentState as any).variables) - const deployedVariables = normalizeVariables((deployedState as any).variables) + const currentVariables = normalizeVariables(currentState.variables) + const deployedVariables = normalizeVariables(deployedState.variables) const normalizedCurrentVars = normalizeValue( Object.fromEntries(Object.entries(currentVariables).map(([id, v]) => [id, sanitizeVariable(v)])) diff --git a/apps/sim/lib/workflows/comparison/normalize.test.ts b/apps/sim/lib/workflows/comparison/normalize.test.ts index c144694564..ca22205876 100644 --- a/apps/sim/lib/workflows/comparison/normalize.test.ts +++ b/apps/sim/lib/workflows/comparison/normalize.test.ts @@ -2,6 +2,7 @@ * Tests for workflow normalization utilities */ import { describe, expect, it } from 'vitest' +import type { Loop, Parallel } from '@/stores/workflows/workflow/types' import { normalizedStringify, normalizeEdge, @@ -39,7 +40,7 @@ describe('Workflow Normalization Utilities', () => { it.concurrent('should sort object keys alphabetically', () => { const input = { zebra: 1, apple: 2, mango: 3 } - const result = normalizeValue(input) + const result = normalizeValue(input) as Record expect(Object.keys(result)).toEqual(['apple', 'mango', 'zebra']) }) @@ -55,7 +56,10 @@ describe('Workflow Normalization Utilities', () => { }, first: 'value', } - const result = normalizeValue(input) + const result = normalizeValue(input) as { + first: string + outer: { z: number; a: { y: number; b: number } } + } expect(Object.keys(result)).toEqual(['first', 'outer']) expect(Object.keys(result.outer)).toEqual(['a', 'z']) @@ -72,11 +76,11 @@ describe('Workflow Normalization Utilities', () => { it.concurrent('should handle arrays with mixed types', () => { const input = [1, 'string', { b: 2, a: 1 }, null, [3, 2, 1]] - const result = normalizeValue(input) + const result = normalizeValue(input) as unknown[] expect(result[0]).toBe(1) expect(result[1]).toBe('string') - expect(Object.keys(result[2])).toEqual(['a', 'b']) + expect(Object.keys(result[2] as Record)).toEqual(['a', 'b']) expect(result[3]).toBe(null) expect(result[4]).toEqual([3, 2, 1]) // Array order preserved }) @@ -94,7 +98,9 @@ describe('Workflow Normalization Utilities', () => { }, }, } - const result = normalizeValue(input) + const result = normalizeValue(input) as { + level1: { level2: { level3: { level4: { z: string; a: string } } } } + } expect(Object.keys(result.level1.level2.level3.level4)).toEqual(['a', 'z']) }) @@ -143,7 +149,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "for" loop type', () => { - const loop = { + const loop: Loop & { extraField?: string } = { id: 'loop1', nodes: ['block1', 'block2'], loopType: 'for', @@ -164,7 +170,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "forEach" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop2', nodes: ['block1'], loopType: 'forEach', @@ -183,10 +189,11 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "while" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop3', nodes: ['block1', 'block2', 'block3'], loopType: 'while', + iterations: 0, whileCondition: ' === true', doWhileCondition: 'should-be-excluded', } @@ -201,10 +208,11 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "doWhile" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop4', nodes: ['block1'], loopType: 'doWhile', + iterations: 0, doWhileCondition: ' < 100', whileCondition: 'should-be-excluded', } @@ -218,11 +226,11 @@ describe('Workflow Normalization Utilities', () => { }) }) - it.concurrent('should handle unknown loop type with base fields only', () => { - const loop = { + it.concurrent('should extract only relevant fields for for loop type', () => { + const loop: Loop = { id: 'loop5', nodes: ['block1'], - loopType: 'unknown', + loopType: 'for', iterations: 5, forEachItems: 'items', } @@ -231,7 +239,8 @@ describe('Workflow Normalization Utilities', () => { expect(result).toEqual({ id: 'loop5', nodes: ['block1'], - loopType: 'unknown', + loopType: 'for', + iterations: 5, }) }) }) @@ -243,7 +252,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "count" parallel type', () => { - const parallel = { + const parallel: Parallel & { extraField?: string } = { id: 'parallel1', nodes: ['block1', 'block2'], parallelType: 'count', @@ -262,7 +271,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "collection" parallel type', () => { - const parallel = { + const parallel: Parallel = { id: 'parallel2', nodes: ['block1'], parallelType: 'collection', @@ -279,11 +288,11 @@ describe('Workflow Normalization Utilities', () => { }) }) - it.concurrent('should handle unknown parallel type with base fields only', () => { - const parallel = { + it.concurrent('should include base fields for undefined parallel type', () => { + const parallel: Parallel = { id: 'parallel3', nodes: ['block1'], - parallelType: 'unknown', + parallelType: undefined, count: 5, distribution: 'items', } @@ -292,7 +301,7 @@ describe('Workflow Normalization Utilities', () => { expect(result).toEqual({ id: 'parallel3', nodes: ['block1'], - parallelType: 'unknown', + parallelType: undefined, }) }) }) @@ -312,7 +321,7 @@ describe('Workflow Normalization Utilities', () => { const tools = [ { id: 'tool1', name: 'Search', isExpanded: true }, { id: 'tool2', name: 'Calculator', isExpanded: false }, - { id: 'tool3', name: 'Weather' }, // No isExpanded field + { id: 'tool3', name: 'Weather' }, ] const result = sanitizeTools(tools) @@ -365,7 +374,7 @@ describe('Workflow Normalization Utilities', () => { const inputFormat = [ { id: 'input1', name: 'Name', value: 'John', collapsed: true }, { id: 'input2', name: 'Age', value: 25, collapsed: false }, - { id: 'input3', name: 'Email' }, // No value or collapsed + { id: 'input3', name: 'Email' }, ] const result = sanitizeInputFormat(inputFormat) diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index bbc60c81ae..571f201138 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -3,12 +3,15 @@ * Used by both client-side signature computation and server-side comparison. */ +import type { Edge } from 'reactflow' +import type { Loop, Parallel, Variable } from '@/stores/workflows/workflow/types' + /** * Normalizes a value for consistent comparison by sorting object keys recursively * @param value - The value to normalize * @returns A normalized version of the value with sorted keys */ -export function normalizeValue(value: any): any { +export function normalizeValue(value: unknown): unknown { if (value === null || value === undefined || typeof value !== 'object') { return value } @@ -17,9 +20,9 @@ export function normalizeValue(value: any): any { return value.map(normalizeValue) } - const sorted: Record = {} - for (const key of Object.keys(value).sort()) { - sorted[key] = normalizeValue(value[key]) + const sorted: Record = {} + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = normalizeValue((value as Record)[key]) } return sorted } @@ -29,19 +32,30 @@ export function normalizeValue(value: any): any { * @param value - The value to normalize and stringify * @returns A normalized JSON string */ -export function normalizedStringify(value: any): string { +export function normalizedStringify(value: unknown): string { return JSON.stringify(normalizeValue(value)) } +/** Normalized loop result type with only essential fields */ +interface NormalizedLoop { + id: string + nodes: string[] + loopType: Loop['loopType'] + iterations?: number + forEachItems?: Loop['forEachItems'] + whileCondition?: string + doWhileCondition?: string +} + /** * Normalizes a loop configuration by extracting only the relevant fields for the loop type * @param loop - The loop configuration object * @returns Normalized loop with only relevant fields */ -export function normalizeLoop(loop: any): any { +export function normalizeLoop(loop: Loop | null | undefined): NormalizedLoop | null | undefined { if (!loop) return loop const { id, nodes, loopType, iterations, forEachItems, whileCondition, doWhileCondition } = loop - const base: any = { id, nodes, loopType } + const base: Pick = { id, nodes, loopType } switch (loopType) { case 'for': @@ -57,15 +71,30 @@ export function normalizeLoop(loop: any): any { } } +/** Normalized parallel result type with only essential fields */ +interface NormalizedParallel { + id: string + nodes: string[] + parallelType: Parallel['parallelType'] + count?: number + distribution?: Parallel['distribution'] +} + /** * Normalizes a parallel configuration by extracting only the relevant fields for the parallel type * @param parallel - The parallel configuration object * @returns Normalized parallel with only relevant fields */ -export function normalizeParallel(parallel: any): any { +export function normalizeParallel( + parallel: Parallel | null | undefined +): NormalizedParallel | null | undefined { if (!parallel) return parallel const { id, nodes, parallelType, count, distribution } = parallel - const base: any = { id, nodes, parallelType } + const base: Pick = { + id, + nodes, + parallelType, + } switch (parallelType) { case 'count': @@ -77,23 +106,37 @@ export function normalizeParallel(parallel: any): any { } } +/** Tool configuration with optional UI-only isExpanded field */ +type ToolWithExpanded = Record & { isExpanded?: boolean } + /** * Sanitizes tools array by removing UI-only fields like isExpanded * @param tools - Array of tool configurations * @returns Sanitized tools array */ -export function sanitizeTools(tools: any[] | undefined): any[] { +export function sanitizeTools(tools: unknown[] | undefined): Record[] { if (!Array.isArray(tools)) return [] - return tools.map(({ isExpanded, ...rest }) => rest) + return tools.map((tool) => { + if (tool && typeof tool === 'object' && !Array.isArray(tool)) { + const { isExpanded, ...rest } = tool as ToolWithExpanded + return rest + } + return tool as Record + }) } +/** Variable with optional UI-only validationError field */ +type VariableWithValidation = Variable & { validationError?: string } + /** * Sanitizes a variable by removing UI-only fields like validationError * @param variable - The variable object * @returns Sanitized variable object */ -export function sanitizeVariable(variable: any): any { +export function sanitizeVariable( + variable: VariableWithValidation | null | undefined +): Omit | null | undefined { if (!variable || typeof variable !== 'object') return variable const { validationError, ...rest } = variable return rest @@ -105,21 +148,38 @@ export function sanitizeVariable(variable: any): any { * @param variables - The variables to normalize * @returns A normalized variables object */ -export function normalizeVariables(variables: any): Record { +export function normalizeVariables(variables: unknown): Record { if (!variables) return {} if (Array.isArray(variables)) return {} if (typeof variables !== 'object') return {} - return variables + return variables as Record } +/** Input format item with optional UI-only fields */ +type InputFormatItem = Record & { value?: unknown; collapsed?: boolean } + /** * Sanitizes inputFormat array by removing UI-only fields like value and collapsed * @param inputFormat - Array of input format configurations * @returns Sanitized input format array */ -export function sanitizeInputFormat(inputFormat: any[] | undefined): any[] { +export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record[] { if (!Array.isArray(inputFormat)) return [] - return inputFormat.map(({ value, collapsed, ...rest }) => rest) + return inputFormat.map((item) => { + if (item && typeof item === 'object' && !Array.isArray(item)) { + const { value, collapsed, ...rest } = item as InputFormatItem + return rest + } + return item as Record + }) +} + +/** Normalized edge with only connection-relevant fields */ +interface NormalizedEdge { + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null } /** @@ -127,12 +187,7 @@ export function sanitizeInputFormat(inputFormat: any[] | undefined): any[] { * @param edge - The edge object * @returns Normalized edge with only connection fields */ -export function normalizeEdge(edge: any): { - source: string - sourceHandle?: string - target: string - targetHandle?: string -} { +export function normalizeEdge(edge: Edge): NormalizedEdge { return { source: edge.source, sourceHandle: edge.sourceHandle, @@ -147,8 +202,18 @@ export function normalizeEdge(edge: any): { * @returns Sorted array of normalized edges */ export function sortEdges( - edges: Array<{ source: string; sourceHandle?: string; target: string; targetHandle?: string }> -): Array<{ source: string; sourceHandle?: string; target: string; targetHandle?: string }> { + edges: Array<{ + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null + }> +): Array<{ + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null +}> { return [...edges].sort((a, b) => `${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare( `${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}` diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 014febabc5..2fb757ba49 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,6 +1,15 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +/** Condition type for SubBlock visibility - mirrors the inline type from blocks/types.ts */ +interface SubBlockCondition { + field: string + value: string | number | boolean | Array | undefined + not?: boolean + and?: SubBlockCondition +} // Credential types based on actual patterns in the codebase export enum CredentialType { @@ -48,7 +57,9 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ * Extract required credentials from a workflow state * This analyzes all blocks and their subblocks to identify credential requirements */ -export function extractRequiredCredentials(state: any): CredentialRequirement[] { +export function extractRequiredCredentials( + state: Partial | null | undefined +): CredentialRequirement[] { const credentials: CredentialRequirement[] = [] const seen = new Set() @@ -57,7 +68,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] } // Process each block - Object.values(state.blocks).forEach((block: any) => { + Object.values(state.blocks).forEach((block: BlockState) => { if (!block?.type) return const blockConfig = getBlock(block.type) @@ -104,8 +115,8 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] }) }) - // Helper to check visibility, respecting mode and conditions - function isSubBlockVisible(block: any, subBlockConfig: SubBlockConfig): boolean { + /** Helper to check visibility, respecting mode and conditions */ + function isSubBlockVisible(block: BlockState, subBlockConfig: SubBlockConfig): boolean { const mode = subBlockConfig.mode ?? 'both' if (mode === 'trigger' && !block?.triggerMode) return false if (mode === 'basic' && block?.advancedMode) return false @@ -118,7 +129,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] ? subBlockConfig.condition() : subBlockConfig.condition - const evaluate = (cond: any): boolean => { + const evaluate = (cond: SubBlockCondition): boolean => { const currentValue = block?.subBlocks?.[cond.field]?.value const expected = cond.value @@ -126,7 +137,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] expected === undefined ? true : Array.isArray(expected) - ? expected.includes(currentValue) + ? expected.includes(currentValue as string) : currentValue === expected if (cond.not) match = !match @@ -161,6 +172,12 @@ function formatFieldName(fieldName: string): string { .join(' ') } +/** Block state with mutable subBlocks for sanitization */ +interface MutableBlockState extends Omit { + subBlocks: Record + data?: Record +} + /** * Remove malformed subBlocks from a block that may have been created by bugs. * This includes subBlocks with: @@ -168,12 +185,12 @@ function formatFieldName(fieldName: string): string { * - Missing required `id` field * - Type "unknown" (indicates malformed data) */ -function removeMalformedSubBlocks(block: any): void { +function removeMalformedSubBlocks(block: MutableBlockState): void { if (!block.subBlocks) return const keysToRemove: string[] = [] - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + Object.entries(block.subBlocks).forEach(([key, subBlock]) => { // Flag subBlocks with invalid keys (literal "undefined" string) if (key === 'undefined') { keysToRemove.push(key) @@ -187,7 +204,8 @@ function removeMalformedSubBlocks(block: any): void { } // Flag subBlocks with type "unknown" (malformed data) - if (subBlock.type === 'unknown') { + // Cast to string for comparison since SubBlockType doesn't include 'unknown' + if ((subBlock.type as string) === 'unknown') { keysToRemove.push(key) return } @@ -204,6 +222,12 @@ function removeMalformedSubBlocks(block: any): void { }) } +/** Sanitized workflow state structure */ +interface SanitizedWorkflowState { + blocks?: Record + [key: string]: unknown +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -212,18 +236,18 @@ function removeMalformedSubBlocks(block: any): void { * @param options - Options for sanitization behavior */ export function sanitizeWorkflowForSharing( - state: any, + state: Partial | null | undefined, options: { preserveEnvVars?: boolean // Keep {{VAR}} references for export } = {} -): any { - const sanitized = JSON.parse(JSON.stringify(state)) // Deep clone +): SanitizedWorkflowState { + const sanitized = JSON.parse(JSON.stringify(state)) as SanitizedWorkflowState // Deep clone if (!sanitized?.blocks) { return sanitized } - Object.values(sanitized.blocks).forEach((block: any) => { + Object.values(sanitized.blocks).forEach((block: MutableBlockState) => { if (!block?.type) return // First, remove any malformed subBlocks that may have been created by bugs @@ -239,7 +263,7 @@ export function sanitizeWorkflowForSharing( // Clear OAuth credentials (type: 'oauth-input') if (subBlockConfig.type === 'oauth-input') { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } // Clear secret fields (password: true) @@ -247,24 +271,24 @@ export function sanitizeWorkflowForSharing( // Preserve environment variable references if requested if ( options.preserveEnvVars && - typeof subBlock.value === 'string' && + typeof subBlock?.value === 'string' && subBlock.value.startsWith('{{') && subBlock.value.endsWith('}}') ) { // Keep the env var reference } else { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } } // Clear workspace-specific selectors else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } // Clear workspace-specific fields by ID else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } } }) @@ -272,9 +296,9 @@ export function sanitizeWorkflowForSharing( // Process subBlocks without config (fallback) if (block.subBlocks) { - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + Object.entries(block.subBlocks).forEach(([key, subBlock]) => { // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { subBlock.value = null } }) @@ -282,14 +306,14 @@ export function sanitizeWorkflowForSharing( // Clear data field (for backward compatibility) if (block.data) { - Object.entries(block.data).forEach(([key, value]: [string, any]) => { + Object.entries(block.data).forEach(([key]) => { // Clear anything that looks like credentials if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { - block.data[key] = null + block.data![key] = null } // Clear workspace-specific data if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { - block.data[key] = null + block.data![key] = null } }) } @@ -302,7 +326,9 @@ export function sanitizeWorkflowForSharing( * Sanitize workflow state for templates (removes credentials and workspace data) * Wrapper for backward compatibility */ -export function sanitizeCredentials(state: any): any { +export function sanitizeCredentials( + state: Partial | null | undefined +): SanitizedWorkflowState { return sanitizeWorkflowForSharing(state, { preserveEnvVars: false }) } @@ -310,6 +336,8 @@ export function sanitizeCredentials(state: any): any { * Sanitize workflow state for export (preserves env vars) * Convenience wrapper for workflow export */ -export function sanitizeForExport(state: any): any { +export function sanitizeForExport( + state: Partial | null | undefined +): SanitizedWorkflowState { return sanitizeWorkflowForSharing(state, { preserveEnvVars: true }) } diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 3efb6831ae..f22365d145 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -245,10 +245,10 @@ function computeFieldDiff( const unchangedFields: string[] = [] // Check basic fields - const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] + const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] as const for (const field of fieldsToCheck) { - const currentValue = (currentBlock as any)[field] - const proposedValue = (proposedBlock as any)[field] + const currentValue = currentBlock[field] + const proposedValue = proposedBlock[field] if (JSON.stringify(currentValue) !== JSON.stringify(proposedValue)) { changedFields.push(field) } else if (currentValue !== undefined) { @@ -363,7 +363,7 @@ export class WorkflowDiffEngine { } // Call the API route to create the diff - const body: any = { + const body: Record = { jsonContent, currentWorkflowState: mergedBaseline, } @@ -859,7 +859,7 @@ export class WorkflowDiffEngine { const proposedEdgeSet = new Set() // Create edge identifiers for current state (using sim-agent format) - mergedBaseline.edges.forEach((edge: any) => { + mergedBaseline.edges.forEach((edge: Edge) => { const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}` currentEdgeSet.add(edgeId) }) @@ -992,7 +992,7 @@ export class WorkflowDiffEngine { } // Call the API route to merge the diff - const body: any = { + const body: Record = { existingDiff: this.currentDiff, jsonContent, } diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index b16e6ea820..ce6f4c2c0d 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -5,6 +5,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +import type { ExecutionResult, StreamingExecution } from '@/executor/types' const logger = createLogger('WorkflowExecution') @@ -13,8 +14,8 @@ export interface ExecuteWorkflowOptions { selectedOutputs?: string[] isSecureMode?: boolean workflowTriggerType?: 'api' | 'chat' - onStream?: (streamingExec: any) => Promise - onBlockComplete?: (blockId: string, output: any) => Promise + onStream?: (streamingExec: StreamingExecution) => Promise + onBlockComplete?: (blockId: string, output: unknown) => Promise skipLoggingComplete?: boolean } @@ -29,11 +30,11 @@ export interface WorkflowInfo { export async function executeWorkflow( workflow: WorkflowInfo, requestId: string, - input: any | undefined, + input: unknown | undefined, actorUserId: string, streamConfig?: ExecuteWorkflowOptions, providedExecutionId?: string -): Promise { +): Promise { if (!workflow.workspaceId) { throw new Error(`Workflow ${workflow.id} has no workspaceId`) } @@ -71,7 +72,7 @@ export async function executeWorkflow( callbacks: { onStream: streamConfig?.onStream, onBlockComplete: streamConfig?.onBlockComplete - ? async (blockId: string, _blockName: string, _blockType: string, output: any) => { + ? async (blockId: string, _blockName: string, _blockType: string, output: unknown) => { await streamConfig.onBlockComplete!(blockId, output) } : undefined, @@ -119,7 +120,7 @@ export async function executeWorkflow( } return result - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Workflow execution failed:`, error) throw error } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 9e81d8711a..0eeb946542 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -19,8 +19,12 @@ import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { Executor } from '@/executor' import { REFERENCE } from '@/executor/constants' import type { ExecutionSnapshot } from '@/executor/execution/snapshot' -import type { ExecutionCallbacks, IterationContext } from '@/executor/execution/types' -import type { ExecutionResult } from '@/executor/types' +import type { + ContextExtensions, + ExecutionCallbacks, + IterationContext, +} from '@/executor/execution/types' +import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { Serializer } from '@/serializer' import { mergeSubblockState } from '@/stores/workflows/server-utils' @@ -41,7 +45,7 @@ export interface ExecuteWorkflowCoreOptions { abortSignal?: AbortSignal } -function parseVariableValueByType(value: any, type: string): any { +function parseVariableValueByType(value: unknown, type: string): unknown { if (value === null || value === undefined) { switch (type) { case 'number': @@ -262,7 +266,7 @@ export async function executeWorkflowCore( const filteredEdges = edges // Check if this is a resume execution before trigger resolution - const resumeFromSnapshot = (metadata as any).resumeFromSnapshot === true + const resumeFromSnapshot = metadata.resumeFromSnapshot === true const resumePendingQueue = snapshot.state?.pendingQueue let resolvedTriggerBlockId = triggerBlockId @@ -321,7 +325,7 @@ export async function executeWorkflowCore( blockId: string, blockName: string, blockType: string, - output: any, + output: { input?: unknown; output: NormalizedBlockOutput; executionTime: number }, iterationContext?: IterationContext ) => { await loggingSession.onBlockComplete(blockId, blockName, blockType, output) @@ -330,7 +334,7 @@ export async function executeWorkflowCore( } } - const contextExtensions: any = { + const contextExtensions: ContextExtensions = { stream: !!onStream, selectedOutputs, executionId, @@ -342,7 +346,12 @@ export async function executeWorkflowCore( onStream, resumeFromSnapshot, resumePendingQueue, - remainingEdges: snapshot.state?.remainingEdges, + remainingEdges: snapshot.state?.remainingEdges?.map((edge) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + })), dagIncomingEdges: snapshot.state?.dagIncomingEdges, snapshotState: snapshot.state, metadata, @@ -363,7 +372,7 @@ export async function executeWorkflowCore( // Convert initial workflow variables to their native types if (workflowVariables) { for (const [varId, variable] of Object.entries(workflowVariables)) { - const v = variable as any + const v = variable as { value?: unknown; type?: string } if (v.value !== undefined && v.type) { v.value = parseVariableValueByType(v.value, v.type) } @@ -432,18 +441,23 @@ export async function executeWorkflowCore( }) return result - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Execution failed:`, error) - const executionResult = (error as any)?.executionResult + const errorWithResult = error as { + executionResult?: ExecutionResult + message?: string + stack?: string + } + const executionResult = errorWithResult?.executionResult const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } await loggingSession.safeCompleteWithError({ endedAt: new Date().toISOString(), totalDurationMs: executionResult?.metadata?.duration || 0, error: { - message: error.message || 'Execution failed', - stackTrace: error.stack, + message: errorWithResult?.message || 'Execution failed', + stackTrace: errorWithResult?.stack, }, traceSpans, }) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 5c10de594b..f695e8dc69 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -2,13 +2,14 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, desc, eq, inArray, lt, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, lt, type SQL, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionResult, PausePoint, SerializedSnapshot } from '@/executor/types' +import type { SerializedConnection } from '@/serializer/types' const logger = createLogger('HumanInTheLoopManager') @@ -18,7 +19,7 @@ interface ResumeQueueEntrySummary { parentExecutionId: string newExecutionId: string contextId: string - resumeInput: any + resumeInput: unknown status: string queuedAt: string | null claimedAt: string | null @@ -69,7 +70,7 @@ interface PersistPauseResultArgs { interface EnqueueResumeArgs { executionId: string contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -85,7 +86,7 @@ type EnqueueResumeResult = resumeEntryId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -94,7 +95,7 @@ interface StartResumeExecutionArgs { resumeExecutionId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -365,7 +366,7 @@ export class PauseResumeManager { resumeExecutionId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string }): Promise { const { resumeExecutionId, pausedExecution, contextId, resumeInput, userId } = args @@ -408,9 +409,8 @@ export class PauseResumeManager { const rawPauseBlockId = pausePoint.blockId ?? contextId const pauseBlockId = PauseResumeManager.normalizePauseBlockId(rawPauseBlockId) - const dagIncomingEdgesFromSnapshot: Record | undefined = ( - baseSnapshot.state as any - )?.dagIncomingEdges + const dagIncomingEdgesFromSnapshot: Record | undefined = + baseSnapshot.state?.dagIncomingEdges const downstreamBlocks = dagIncomingEdgesFromSnapshot ? Object.entries(dagIncomingEdgesFromSnapshot) @@ -424,9 +424,10 @@ export class PauseResumeManager { .map(([nodeId]) => nodeId) : baseSnapshot.workflow.connections .filter( - (conn: any) => PauseResumeManager.normalizePauseBlockId(conn.source) === pauseBlockId + (conn: SerializedConnection) => + PauseResumeManager.normalizePauseBlockId(conn.source) === pauseBlockId ) - .map((conn: any) => conn.target) + .map((conn: SerializedConnection) => conn.target) logger.info('Found downstream blocks', { pauseBlockId, @@ -448,7 +449,7 @@ export class PauseResumeManager { if (stateCopy) { const dagIncomingEdges: Record | undefined = - (stateCopy as any)?.dagIncomingEdges || dagIncomingEdgesFromSnapshot + stateCopy.dagIncomingEdges || dagIncomingEdgesFromSnapshot // Calculate the pause duration (time from pause to resume) const pauseDurationMs = pausedExecution.pausedAt @@ -617,11 +618,11 @@ export class PauseResumeManager { // If we didn't find any edges via the DAG snapshot, fall back to workflow connections if (edgesToRemove.length === 0 && baseSnapshot.workflow.connections?.length) { edgesToRemove = baseSnapshot.workflow.connections - .filter((conn: any) => + .filter((conn: SerializedConnection) => completedPauseContexts.has(PauseResumeManager.normalizePauseBlockId(conn.source)) ) - .map((conn: any) => ({ - id: conn.id ?? `${conn.source}→${conn.target}`, + .map((conn: SerializedConnection) => ({ + id: `${conn.source}→${conn.target}`, source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle, @@ -630,11 +631,11 @@ export class PauseResumeManager { } } else { edgesToRemove = baseSnapshot.workflow.connections - .filter((conn: any) => + .filter((conn: SerializedConnection) => completedPauseContexts.has(PauseResumeManager.normalizePauseBlockId(conn.source)) ) - .map((conn: any) => ({ - id: conn.id ?? `${conn.source}→${conn.target}`, + .map((conn: SerializedConnection) => ({ + id: `${conn.source}→${conn.target}`, source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle, @@ -913,7 +914,7 @@ export class PauseResumeManager { }): Promise { const { workflowId, status } = options - let whereClause: any = eq(pausedExecutions.workflowId, workflowId) + let whereClause: SQL | undefined = eq(pausedExecutions.workflowId, workflowId) if (status) { const statuses = Array.isArray(status) @@ -924,7 +925,7 @@ export class PauseResumeManager { if (statuses.length === 1) { whereClause = and(whereClause, eq(pausedExecutions.status, statuses[0])) } else if (statuses.length > 1) { - whereClause = and(whereClause, inArray(pausedExecutions.status, statuses as any)) + whereClause = and(whereClause, inArray(pausedExecutions.status, statuses)) } } @@ -1129,16 +1130,16 @@ export class PauseResumeManager { } private static mapPausePoints( - pausePoints: any, + pausePoints: unknown, queuePositions?: Map, latestEntries?: Map ): PausePointWithQueue[] { - const record = pausePoints as Record + const record = pausePoints as Record | null if (!record) { return [] } - return Object.values(record).map((point: any) => { + return Object.values(record).map((point: PausePoint) => { const queuePosition = queuePositions?.get(point.contextId ?? '') ?? null const latestEntry = latestEntries?.get(point.contextId ?? '') diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index f5dbd52a92..b446ea1083 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import JSZip from 'jszip' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowImportExport') @@ -14,12 +14,7 @@ export interface WorkflowExportData { folderId?: string | null } state: WorkflowState - variables?: Array<{ - id: string - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any - }> + variables?: Record } export interface FolderExportData { diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index b115321202..d6ccaa90f9 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -9,7 +9,7 @@ import { workflowSubflows, } from '@sim/db' import { createLogger } from '@sim/logger' -import type { InferSelectModel } from 'drizzle-orm' +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import { and, desc, eq, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' @@ -22,6 +22,8 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w const logger = createLogger('WorkflowDBHelpers') export type WorkflowDeploymentVersion = InferSelectModel +type WebhookRecord = InferSelectModel +type SubflowInsert = InferInsertModel export interface WorkflowDeploymentVersionResponse { id: string @@ -43,7 +45,7 @@ export interface NormalizedWorkflowData { export interface DeployedWorkflowData extends NormalizedWorkflowData { deploymentVersionId: string - variables?: Record + variables?: Record } export async function blockExistsInDeployment( @@ -96,7 +98,7 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise } + const state = active.state as WorkflowState & { variables?: Record } return { blocks: state.blocks || {}, @@ -336,7 +338,7 @@ export async function saveWorkflowToNormalizedTables( // Start a transaction await db.transaction(async (tx) => { // Snapshot existing webhooks before deletion to preserve them through the cycle - let existingWebhooks: any[] = [] + let existingWebhooks: WebhookRecord[] = [] try { existingWebhooks = await tx.select().from(webhook).where(eq(webhook.workflowId, workflowId)) } catch (webhookError) { @@ -392,7 +394,7 @@ export async function saveWorkflowToNormalizedTables( } // Insert subflows (loops and parallels) - const subflowInserts: any[] = [] + const subflowInserts: SubflowInsert[] = [] // Add loops Object.values(canonicalLoops).forEach((loop) => { @@ -571,7 +573,7 @@ export async function deployWorkflow(params: { const blockTypeCounts: Record = {} for (const block of Object.values(currentState.blocks)) { - const blockType = (block as any).type || 'unknown' + const blockType = block.type || 'unknown' blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 } @@ -605,11 +607,33 @@ export async function deployWorkflow(params: { } } +/** Input state for ID regeneration - partial to handle external sources */ +export interface RegenerateStateInput { + blocks?: Record + edges?: Edge[] + loops?: Record + parallels?: Record + lastSaved?: number + variables?: Record + metadata?: Record +} + +/** Output state after ID regeneration */ +interface RegenerateStateOutput { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + lastSaved: number + variables?: Record + metadata?: Record +} + /** * Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates * Returns a new state with all IDs regenerated and references updated */ -export function regenerateWorkflowStateIds(state: any): any { +export function regenerateWorkflowStateIds(state: RegenerateStateInput): RegenerateStateOutput { // Create ID mappings const blockIdMapping = new Map() const edgeIdMapping = new Map() @@ -624,7 +648,7 @@ export function regenerateWorkflowStateIds(state: any): any { // Map edge IDs - ;(state.edges || []).forEach((edge: any) => { + ;(state.edges || []).forEach((edge: Edge) => { edgeIdMapping.set(edge.id, crypto.randomUUID()) }) @@ -639,28 +663,28 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Second pass: Create new state with regenerated IDs and updated references - const newBlocks: Record = {} - const newEdges: any[] = [] - const newLoops: Record = {} - const newParallels: Record = {} + const newBlocks: Record = {} + const newEdges: Edge[] = [] + const newLoops: Record = {} + const newParallels: Record = {} // Regenerate blocks with updated references - Object.entries(state.blocks || {}).forEach(([oldId, block]: [string, any]) => { + Object.entries(state.blocks || {}).forEach(([oldId, block]) => { const newId = blockIdMapping.get(oldId)! - const newBlock = { ...block, id: newId } + const newBlock: BlockState = { ...block, id: newId } // Update parentId reference if it exists if (newBlock.data?.parentId) { const newParentId = blockIdMapping.get(newBlock.data.parentId) if (newParentId) { - newBlock.data.parentId = newParentId + newBlock.data = { ...newBlock.data, parentId: newParentId } } } // Update any block references in subBlocks if (newBlock.subBlocks) { - const updatedSubBlocks: Record = {} - Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]: [string, any]) => { + const updatedSubBlocks: Record = {} + Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]) => { const updatedSubBlock = { ...subBlock } // If subblock value contains block references, update them @@ -668,7 +692,7 @@ export function regenerateWorkflowStateIds(state: any): any { typeof updatedSubBlock.value === 'string' && blockIdMapping.has(updatedSubBlock.value) ) { - updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) + updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value } updatedSubBlocks[subId] = updatedSubBlock @@ -681,7 +705,7 @@ export function regenerateWorkflowStateIds(state: any): any { // Regenerate edges with updated source/target references - ;(state.edges || []).forEach((edge: any) => { + ;(state.edges || []).forEach((edge: Edge) => { const newId = edgeIdMapping.get(edge.id)! const newSource = blockIdMapping.get(edge.source) || edge.source const newTarget = blockIdMapping.get(edge.target) || edge.target @@ -695,9 +719,9 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Regenerate loops with updated node references - Object.entries(state.loops || {}).forEach(([oldId, loop]: [string, any]) => { + Object.entries(state.loops || {}).forEach(([oldId, loop]) => { const newId = loopIdMapping.get(oldId)! - const newLoop = { ...loop, id: newId } + const newLoop: Loop = { ...loop, id: newId } // Update nodes array with new block IDs if (newLoop.nodes) { @@ -708,9 +732,9 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Regenerate parallels with updated node references - Object.entries(state.parallels || {}).forEach(([oldId, parallel]: [string, any]) => { + Object.entries(state.parallels || {}).forEach(([oldId, parallel]) => { const newId = parallelIdMapping.get(oldId)! - const newParallel = { ...parallel, id: newId } + const newParallel: Parallel = { ...parallel, id: newId } // Update nodes array with new block IDs if (newParallel.nodes) { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index eb062599f0..8ee5b01957 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -59,26 +59,36 @@ export interface ExportWorkflowState { id: string name: string type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any + value: unknown }> } } +/** Condition structure for sanitization */ +interface SanitizedCondition { + id: string + title: string + value: string +} + /** * Sanitize condition blocks by removing UI-specific metadata * Returns cleaned JSON string (not parsed array) */ function sanitizeConditions(conditionsJson: string): string { try { - const conditions = JSON.parse(conditionsJson) + const conditions: unknown = JSON.parse(conditionsJson) if (!Array.isArray(conditions)) return conditionsJson // Keep only id, title, and value - remove UI state - const cleaned = conditions.map((cond: any) => ({ - id: cond.id, - title: cond.title, - value: cond.value || '', - })) + const cleaned: SanitizedCondition[] = conditions.map((cond: unknown) => { + const condition = cond as Record + return { + id: String(condition.id ?? ''), + title: String(condition.title ?? ''), + value: String(condition.value ?? ''), + } + }) return JSON.stringify(cleaned) } catch { @@ -86,11 +96,50 @@ function sanitizeConditions(conditionsJson: string): string { } } +/** Tool input structure for sanitization */ +interface ToolInput { + type: string + customToolId?: string + schema?: { + type?: string + function?: { + name: string + description?: string + parameters?: unknown + } + } + code?: string + title?: string + toolId?: string + usageControl?: string + isExpanded?: boolean + [key: string]: unknown +} + +/** Sanitized tool output structure */ +interface SanitizedTool { + type: string + customToolId?: string + usageControl?: string + title?: string + toolId?: string + schema?: { + type: string + function: { + name: string + description?: string + parameters?: unknown + } + } + code?: string + [key: string]: unknown +} + /** * Sanitize tools array by removing UI state and redundant fields */ -function sanitizeTools(tools: any[]): any[] { - return tools.map((tool) => { +function sanitizeTools(tools: ToolInput[]): SanitizedTool[] { + return tools.map((tool): SanitizedTool => { if (tool.type === 'custom-tool') { // New reference format: minimal fields only if (tool.customToolId && !tool.schema && !tool.code) { @@ -102,7 +151,7 @@ function sanitizeTools(tools: any[]): any[] { } // Legacy inline format: include all fields - const sanitized: any = { + const sanitized: SanitizedTool = { type: tool.type, title: tool.title, toolId: tool.toolId, @@ -129,23 +178,24 @@ function sanitizeTools(tools: any[]): any[] { return sanitized } - const { isExpanded, ...cleanTool } = tool - return cleanTool + const { isExpanded: _isExpanded, ...cleanTool } = tool + return cleanTool as SanitizedTool }) } /** * Sort object keys recursively for consistent comparison */ -function sortKeysRecursively(item: any): any { +function sortKeysRecursively(item: unknown): unknown { if (Array.isArray(item)) { return item.map(sortKeysRecursively) } if (item !== null && typeof item === 'object') { - return Object.keys(item) + const obj = item as Record + return Object.keys(obj) .sort() - .reduce((result: any, key: string) => { - result[key] = sortKeysRecursively(item[key]) + .reduce((result: Record, key: string) => { + result[key] = sortKeysRecursively(obj[key]) return result }, {}) } @@ -183,7 +233,7 @@ function sanitizeSubBlocks( // Sort keys for consistent comparison if (obj && typeof obj === 'object') { - sanitized[key] = sortKeysRecursively(obj) + sanitized[key] = sortKeysRecursively(obj) as Record return } } catch { @@ -201,7 +251,7 @@ function sanitizeSubBlocks( } if (key === 'tools' && Array.isArray(subBlock.value)) { - sanitized[key] = sanitizeTools(subBlock.value) + sanitized[key] = sanitizeTools(subBlock.value as unknown as ToolInput[]) return } @@ -383,7 +433,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { // Use unified sanitization with env var preservation for export const sanitizedState = sanitizeWorkflowForSharing(fullState, { preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows - }) + }) as ExportWorkflowState['state'] return { version: '1.0', diff --git a/apps/sim/lib/workflows/sanitization/validation.ts b/apps/sim/lib/workflows/sanitization/validation.ts index 75e9ef5639..4c25d19981 100644 --- a/apps/sim/lib/workflows/sanitization/validation.ts +++ b/apps/sim/lib/workflows/sanitization/validation.ts @@ -1,20 +1,40 @@ import { createLogger } from '@sim/logger' import { getBlock } from '@/blocks/registry' import { isCustomTool, isMcpTool } from '@/executor/constants' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { getTool } from '@/tools/utils' const logger = createLogger('WorkflowValidation') +/** Tool structure for validation */ +interface AgentTool { + type: string + customToolId?: string + schema?: { + type?: string + function?: { + name?: string + parameters?: { + type?: string + properties?: Record + } + } + } + code?: string + usageControl?: string + [key: string]: unknown +} + /** * Checks if a custom tool has a valid inline schema */ -function isValidCustomToolSchema(tool: any): boolean { +function isValidCustomToolSchema(tool: unknown): boolean { try { if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return true // non-custom tools are validated elsewhere + const t = tool as AgentTool + if (t.type !== 'custom-tool') return true // non-custom tools are validated elsewhere - const schema = tool.schema + const schema = t.schema if (!schema || typeof schema !== 'object') return false const fn = schema.function if (!fn || typeof fn !== 'object') return false @@ -34,14 +54,15 @@ function isValidCustomToolSchema(tool: any): boolean { /** * Checks if a custom tool is a valid reference-only format (new format) */ -function isValidCustomToolReference(tool: any): boolean { +function isValidCustomToolReference(tool: unknown): boolean { try { if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return false + const t = tool as AgentTool + if (t.type !== 'custom-tool') return false // Reference format: has customToolId but no inline schema/code // This is valid - the tool will be loaded dynamically during execution - if (tool.customToolId && typeof tool.customToolId === 'string') { + if (t.customToolId && typeof t.customToolId === 'string') { return true } @@ -51,14 +72,14 @@ function isValidCustomToolReference(tool: any): boolean { } } -export function sanitizeAgentToolsInBlocks(blocks: Record): { - blocks: Record +export function sanitizeAgentToolsInBlocks(blocks: Record): { + blocks: Record warnings: string[] } { const warnings: string[] = [] // Shallow clone to avoid mutating callers - const sanitizedBlocks: Record = { ...blocks } + const sanitizedBlocks: Record = { ...blocks } for (const [blockId, block] of Object.entries(sanitizedBlocks)) { try { @@ -90,10 +111,11 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { const originalLength = value.length const cleaned = value - .filter((tool: any) => { + .filter((tool: unknown) => { // Allow non-custom tools to pass through as-is if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return true + const t = tool as AgentTool + if (t.type !== 'custom-tool') return true // Check if it's a valid reference-only format (new format) if (isValidCustomToolReference(tool)) { @@ -106,21 +128,22 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { logger.warn('Removing invalid custom tool from workflow', { blockId, blockName: block.name, - hasCustomToolId: !!tool.customToolId, - hasSchema: !!tool.schema, + hasCustomToolId: !!t.customToolId, + hasSchema: !!t.schema, }) } return ok }) - .map((tool: any) => { - if (tool.type === 'custom-tool') { + .map((tool: unknown) => { + const t = tool as AgentTool + if (t.type === 'custom-tool') { // For reference-only tools, ensure usageControl default - if (!tool.usageControl) { - tool.usageControl = 'auto' + if (!t.usageControl) { + t.usageControl = 'auto' } // For inline tools (legacy), also ensure code default - if (!tool.customToolId && (!tool.code || typeof tool.code !== 'string')) { - tool.code = '' + if (!t.customToolId && (!t.code || typeof t.code !== 'string')) { + t.code = '' } } return tool @@ -132,13 +155,14 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { ) } - toolsSubBlock.value = cleaned + // Cast cleaned to the expected SubBlockState value type + // The value is a tools array but SubBlockState.value is typed narrowly + toolsSubBlock.value = cleaned as unknown as typeof toolsSubBlock.value // Reassign in case caller uses object identity sanitizedBlocks[blockId] = { ...block, subBlocks: { ...subBlocks, tools: toolsSubBlock } } - } catch (err: any) { - warnings.push( - `Block ${block?.name || blockId}: tools sanitation failed: ${err?.message || String(err)}` - ) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`Block ${block?.name || blockId}: tools sanitation failed: ${message}`) } } @@ -177,7 +201,7 @@ export function validateWorkflowState( } // Validate each block - const sanitizedBlocks: Record = {} + const sanitizedBlocks: Record = {} let hasChanges = false for (const [blockId, block] of Object.entries(workflowState.blocks)) { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index 6a12d78722..b1fe64b637 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -8,7 +8,15 @@ import { encodeSSE } from '@/lib/core/utils/sse' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' -import type { ExecutionResult } from '@/executor/types' +import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' + +/** + * Extended streaming execution type that includes blockId on the execution. + * The runtime passes blockId but the base StreamingExecution type doesn't declare it. + */ +interface StreamingExecutionWithBlockId extends Omit { + execution?: StreamingExecution['execution'] & { blockId?: string } +} const logger = createLogger('WorkflowStreaming') @@ -27,9 +35,9 @@ export interface StreamingResponseOptions { userId: string workspaceId?: string | null isDeployed?: boolean - variables?: Record + variables?: Record } - input: any + input: unknown executingUserId: string streamConfig: StreamingConfig executionId?: string @@ -41,7 +49,7 @@ interface StreamingState { streamCompletionTimes: Map } -function extractOutputValue(output: any, path: string): any { +function extractOutputValue(output: unknown, path: string): unknown { return traverseObjectPath(output, path) } @@ -54,11 +62,11 @@ function buildMinimalResult( selectedOutputs: string[] | undefined, streamedContent: Map, requestId: string -): { success: boolean; error?: string; output: Record } { +): { success: boolean; error?: string; output: Record } { const minimalResult = { success: result.success, error: result.error, - output: {} as Record, + output: {} as Record, } if (!selectedOutputs?.length) { @@ -88,7 +96,7 @@ function buildMinimalResult( continue } - const blockLog = result.logs.find((log: any) => log.blockId === blockId) + const blockLog = result.logs.find((log: BlockLog) => log.blockId === blockId) if (!blockLog?.output) { continue } @@ -99,16 +107,16 @@ function buildMinimalResult( } if (!minimalResult.output[blockId]) { - minimalResult.output[blockId] = Object.create(null) + minimalResult.output[blockId] = Object.create(null) as Record } - minimalResult.output[blockId][path] = value + ;(minimalResult.output[blockId] as Record)[path] = value } return minimalResult } -function updateLogsWithStreamedContent(logs: any[], state: StreamingState): any[] { - return logs.map((log: any) => { +function updateLogsWithStreamedContent(logs: BlockLog[], state: StreamingState): BlockLog[] { + return logs.map((log: BlockLog) => { if (!state.streamedContent.has(log.blockId)) { return log } @@ -168,10 +176,10 @@ export async function createStreamingResponse( state.processedOutputs.add(blockId) } - const onStreamCallback = async (streamingExec: { - stream: ReadableStream - execution?: { blockId?: string } - }) => { + /** + * Callback for handling streaming execution events. + */ + const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { const blockId = streamingExec.execution?.blockId if (!blockId) { logger.warn(`[${requestId}] Streaming execution missing blockId`) @@ -215,7 +223,7 @@ export async function createStreamingResponse( } } - const onBlockCompleteCallback = async (blockId: string, output: any) => { + const onBlockCompleteCallback = async (blockId: string, output: unknown) => { if (!streamConfig.selectedOutputs?.length) { return } diff --git a/apps/sim/lib/workflows/training/compute-edit-sequence.ts b/apps/sim/lib/workflows/training/compute-edit-sequence.ts index b50ce49211..da9798d560 100644 --- a/apps/sim/lib/workflows/training/compute-edit-sequence.ts +++ b/apps/sim/lib/workflows/training/compute-edit-sequence.ts @@ -1,4 +1,7 @@ -import type { CopilotWorkflowState } from '@/lib/workflows/sanitization/json-sanitizer' +import type { + CopilotBlockState, + CopilotWorkflowState, +} from '@/lib/workflows/sanitization/json-sanitizer' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' export interface EditOperation { @@ -7,13 +10,12 @@ export interface EditOperation { params?: { type?: string name?: string - outputs?: Record enabled?: boolean triggerMode?: boolean advancedMode?: boolean - inputs?: Record - connections?: Record - nestedNodes?: Record + inputs?: Record + connections?: Record + nestedNodes?: Record subflowId?: string } } @@ -34,11 +36,11 @@ export interface WorkflowDiff { * Returns map of blockId -> {block, parentId} */ function flattenBlocks( - blocks: Record -): Record { - const flattened: Record = {} + blocks: Record +): Record { + const flattened: Record = {} - const processBlock = (blockId: string, block: any, parentId?: string) => { + const processBlock = (blockId: string, block: CopilotBlockState, parentId?: string) => { flattened[blockId] = { block, parentId } // Recursively process nested nodes @@ -56,23 +58,20 @@ function flattenBlocks( return flattened } -/** - * Extract all edges from blocks with embedded connections (including nested) - */ -function extractAllEdgesFromBlocks(blocks: Record): Array<{ +interface ExtractedEdge { source: string target: string sourceHandle?: string | null targetHandle?: string | null -}> { - const edges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> = [] - - const processBlockConnections = (block: any, blockId: string) => { +} + +/** + * Extract all edges from blocks with embedded connections (including nested) + */ +function extractAllEdgesFromBlocks(blocks: Record): ExtractedEdge[] { + const edges: ExtractedEdge[] = [] + + const processBlockConnections = (block: CopilotBlockState, blockId: string) => { if (block.connections) { Object.entries(block.connections).forEach(([sourceHandle, targets]) => { const targetArray = Array.isArray(targets) ? targets : [targets] @@ -191,7 +190,6 @@ export function computeEditSequence( subflowId: parentId, type: block.type, name: block.name, - outputs: block.outputs, enabled: block.enabled !== undefined ? block.enabled : true, } @@ -296,7 +294,6 @@ export function computeEditSequence( subflowId: endParentId, type: endBlock.type, name: endBlock.name, - outputs: endBlock.outputs, enabled: endBlock.enabled !== undefined ? endBlock.enabled : true, } @@ -359,33 +356,22 @@ export function computeEditSequence( * Extract input values from a block * Works with sanitized format where inputs is Record */ -function extractInputValues(block: any): Record { +function extractInputValues(block: CopilotBlockState): Record { // New sanitized format uses 'inputs' field if (block.inputs) { return { ...block.inputs } } - // Fallback for any legacy data - if (block.subBlocks) { - return { ...block.subBlocks } - } - return {} } +type ConnectionTarget = string | { block: string; handle: string } + /** * Extract connections for a specific block from edges */ -function extractConnections( - blockId: string, - edges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> -): Record { - const connections: Record = {} +function extractConnections(blockId: string, edges: ExtractedEdge[]): Record { + const connections: Record = {} // Find all edges where this block is the source const outgoingEdges = edges.filter((edge) => edge.source === blockId) @@ -410,36 +396,29 @@ function extractConnections( } // Simplify single-element arrays to just the element + const result: Record = {} for (const handle in connections) { - if (Array.isArray(connections[handle]) && connections[handle].length === 1) { - connections[handle] = connections[handle][0] + if (connections[handle].length === 1) { + result[handle] = connections[handle][0] + } else { + result[handle] = connections[handle] } } - return connections + return result } /** * Compute what changed in a block between two states */ function computeBlockChanges( - startBlock: any, - endBlock: any, + startBlock: CopilotBlockState, + endBlock: CopilotBlockState, blockId: string, - startEdges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }>, - endEdges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> -): Record | null { - const changes: Record = {} + startEdges: ExtractedEdge[], + endEdges: ExtractedEdge[] +): Record | null { + const changes: Record = {} let hasChanges = false // Check type change @@ -497,10 +476,10 @@ function computeBlockChanges( * Only returns fields that actually changed or were added */ function computeInputDelta( - startInputs: Record, - endInputs: Record -): Record { - const delta: Record = {} + startInputs: Record, + endInputs: Record +): Record { + const delta: Record = {} for (const key in endInputs) { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 4601f9f32c..af7a919f04 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -6,6 +6,7 @@ import { } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { getTrigger } from '@/triggers' const logger = createLogger('TriggerUtils') @@ -34,12 +35,12 @@ export function isValidStartBlockType(blockType: string): blockType is ValidStar /** * Check if a workflow state has a valid start block */ -export function hasValidStartBlockInState(state: any): boolean { +export function hasValidStartBlockInState(state: WorkflowState | null | undefined): boolean { if (!state?.blocks) { return false } - const startBlock = Object.values(state.blocks).find((block: any) => { + const startBlock = Object.values(state.blocks).find((block: BlockState) => { const blockType = block?.type return isValidStartBlockType(blockType) }) @@ -50,7 +51,7 @@ export function hasValidStartBlockInState(state: any): boolean { /** * Generates mock data based on the output type definition */ -function generateMockValue(type: string, description?: string, fieldName?: string): any { +function generateMockValue(type: string, _description?: string, fieldName?: string): unknown { const name = fieldName || 'value' switch (type) { @@ -88,18 +89,19 @@ function generateMockValue(type: string, description?: string, fieldName?: strin /** * Recursively processes nested output structures */ -function processOutputField(key: string, field: any, depth = 0, maxDepth = 10): any { +function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown { // Prevent infinite recursion if (depth > maxDepth) { return null } if (field && typeof field === 'object' && 'type' in field) { - return generateMockValue(field.type, field.description, key) + const typedField = field as { type: string; description?: string } + return generateMockValue(typedField.type, typedField.description, key) } if (field && typeof field === 'object' && !Array.isArray(field)) { - const nestedObject: Record = {} + const nestedObject: Record = {} for (const [nestedKey, nestedField] of Object.entries(field)) { nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) } @@ -112,8 +114,8 @@ function processOutputField(key: string, field: any, depth = 0, maxDepth = 10): /** * Generates mock payload from outputs object */ -function generateMockPayloadFromOutputs(outputs: Record): Record { - const mockPayload: Record = {} +function generateMockPayloadFromOutputs(outputs: Record): Record { + const mockPayload: Record = {} for (const [key, output] of Object.entries(outputs)) { if (key === 'visualization') { @@ -129,8 +131,8 @@ function generateMockPayloadFromOutputs(outputs: Record): Record -): Record { + outputs: Record +): Record { return generateMockPayloadFromOutputs(outputs) } @@ -395,8 +397,8 @@ export function triggerNeedsMockPayload( */ export function extractTriggerMockPayload< T extends { type: string; subBlocks?: Record }, ->(trigger: StartBlockCandidate): any { - const subBlocks = trigger.block.subBlocks as Record | undefined +>(trigger: StartBlockCandidate): unknown { + const subBlocks = trigger.block.subBlocks as Record | undefined // Determine the trigger ID let triggerId: string diff --git a/apps/sim/lib/workflows/variables/variable-manager.ts b/apps/sim/lib/workflows/variables/variable-manager.ts index d2db3bd109..04ed5b9e49 100644 --- a/apps/sim/lib/workflows/variables/variable-manager.ts +++ b/apps/sim/lib/workflows/variables/variable-manager.ts @@ -16,7 +16,11 @@ export class VariableManager { * @param forExecution Whether this conversion is for execution (true) or storage/display (false) * @returns The value converted to its appropriate type */ - private static convertToNativeType(value: any, type: VariableType, forExecution = false): any { + private static convertToNativeType( + value: unknown, + type: VariableType, + forExecution = false + ): unknown { // Special handling for empty input values during storage if (value === '') { return value // Return empty string for all types during storage @@ -38,7 +42,8 @@ export class VariableManager { } // Remove quotes from string values if present (used by multiple types) - const unquoted = typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value + const unquoted: unknown = + typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value switch (type) { case 'string': // Handle string type the same as plain for compatibility @@ -117,7 +122,7 @@ export class VariableManager { * @returns The formatted string value */ private static formatValue( - value: any, + value: unknown, type: VariableType, context: 'editor' | 'text' | 'code' ): string { @@ -161,7 +166,7 @@ export class VariableManager { * Parses user input and converts it to the appropriate storage format * based on the variable type. */ - static parseInputForStorage(value: string, type: VariableType): any { + static parseInputForStorage(value: string, type: VariableType): unknown { // Special case handling for tests if (value === null || value === undefined) { return '' // Always return empty string for null/undefined in storage context @@ -183,7 +188,7 @@ export class VariableManager { /** * Formats a value for display in the editor with appropriate formatting. */ - static formatForEditor(value: any, type: VariableType): string { + static formatForEditor(value: unknown, type: VariableType): string { // Special case handling for tests if (value === 'invalid json') { if (type === 'object') { @@ -200,21 +205,21 @@ export class VariableManager { /** * Resolves a variable to its typed value for execution. */ - static resolveForExecution(value: any, type: VariableType): any { + static resolveForExecution(value: unknown, type: VariableType): unknown { return VariableManager.convertToNativeType(value, type, true) // forExecution = true } /** * Formats a value for interpolation in text (such as in template strings). */ - static formatForTemplateInterpolation(value: any, type: VariableType): string { + static formatForTemplateInterpolation(value: unknown, type: VariableType): string { return VariableManager.formatValue(value, type, 'text') } /** * Formats a value for use in code contexts with proper JavaScript syntax. */ - static formatForCodeContext(value: any, type: VariableType): string { + static formatForCodeContext(value: unknown, type: VariableType): string { // Special handling for null/undefined in code context if (value === null) return 'null' if (value === undefined) return 'undefined' diff --git a/apps/sim/scripts/export-workflow.ts b/apps/sim/scripts/export-workflow.ts index f842922377..8123cc1237 100755 --- a/apps/sim/scripts/export-workflow.ts +++ b/apps/sim/scripts/export-workflow.ts @@ -70,16 +70,11 @@ async function exportWorkflow(workflowId: string, outputFile?: string): Promise< process.exit(1) } - // Convert variables to array format - let workflowVariables: any[] = [] - if (workflowData.variables && typeof workflowData.variables === 'object') { - workflowVariables = Object.values(workflowData.variables).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) - } + // Get variables in Record format (as stored in database) + type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + const workflowVariables = workflowData.variables as + | Record + | undefined // Prepare export state - match the exact format from the UI const workflowState = { diff --git a/apps/sim/serializer/index.test.ts b/apps/sim/serializer/index.test.ts index a3dbf4ab22..df772efcc3 100644 --- a/apps/sim/serializer/index.test.ts +++ b/apps/sim/serializer/index.test.ts @@ -391,7 +391,7 @@ describe('Serializer', () => { expect(toolsParam).toBeDefined() // Parse tools to verify content - const tools = JSON.parse(toolsParam) + const tools = JSON.parse(toolsParam as string) expect(tools).toHaveLength(2) // Check custom tool diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index bf996579fe..fa2ab45155 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -218,7 +218,7 @@ export class Serializer { position: block.position, config: { tool: '', // Loop blocks don't have tools - params: block.data || {}, // Preserve the block data (parallelType, count, etc.) + params: (block.data || {}) as Record, // Preserve the block data (parallelType, count, etc.) }, inputs: {}, outputs: block.outputs, diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index caf45566e2..4f89bfb71c 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -1,4 +1,4 @@ -import type { BlockOutput, ParamType } from '@/blocks/types' +import type { OutputFieldDefinition, ParamType } from '@/blocks/types' import type { Position } from '@/stores/workflows/workflow/types' export interface SerializedWorkflow { @@ -25,10 +25,10 @@ export interface SerializedBlock { position: Position config: { tool: string - params: Record + params: Record } inputs: Record - outputs: Record + outputs: Record metadata?: { id: string name?: string diff --git a/apps/sim/socket/constants.ts b/apps/sim/socket/constants.ts new file mode 100644 index 0000000000..89aa7020e6 --- /dev/null +++ b/apps/sim/socket/constants.ts @@ -0,0 +1,96 @@ +export const BLOCK_OPERATIONS = { + UPDATE_POSITION: 'update-position', + UPDATE_NAME: 'update-name', + TOGGLE_ENABLED: 'toggle-enabled', + UPDATE_PARENT: 'update-parent', + UPDATE_WIDE: 'update-wide', + UPDATE_ADVANCED_MODE: 'update-advanced-mode', + UPDATE_TRIGGER_MODE: 'update-trigger-mode', + TOGGLE_HANDLES: 'toggle-handles', +} as const + +export type BlockOperation = (typeof BLOCK_OPERATIONS)[keyof typeof BLOCK_OPERATIONS] + +export const BLOCKS_OPERATIONS = { + BATCH_UPDATE_POSITIONS: 'batch-update-positions', + BATCH_ADD_BLOCKS: 'batch-add-blocks', + BATCH_REMOVE_BLOCKS: 'batch-remove-blocks', + BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', + BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', + BATCH_UPDATE_PARENT: 'batch-update-parent', +} as const + +export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS] + +export const EDGE_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', +} as const + +export type EdgeOperation = (typeof EDGE_OPERATIONS)[keyof typeof EDGE_OPERATIONS] + +export const EDGES_OPERATIONS = { + BATCH_ADD_EDGES: 'batch-add-edges', + BATCH_REMOVE_EDGES: 'batch-remove-edges', +} as const + +export type EdgesOperation = (typeof EDGES_OPERATIONS)[keyof typeof EDGES_OPERATIONS] + +export const SUBFLOW_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', + UPDATE: 'update', +} as const + +export type SubflowOperation = (typeof SUBFLOW_OPERATIONS)[keyof typeof SUBFLOW_OPERATIONS] + +export const VARIABLE_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', + UPDATE: 'variable-update', +} as const + +export type VariableOperation = (typeof VARIABLE_OPERATIONS)[keyof typeof VARIABLE_OPERATIONS] + +export const WORKFLOW_OPERATIONS = { + REPLACE_STATE: 'replace-state', +} as const + +export type WorkflowOperation = (typeof WORKFLOW_OPERATIONS)[keyof typeof WORKFLOW_OPERATIONS] + +export const SUBBLOCK_OPERATIONS = { + UPDATE: 'subblock-update', +} as const + +export type SubblockOperation = (typeof SUBBLOCK_OPERATIONS)[keyof typeof SUBBLOCK_OPERATIONS] + +export const OPERATION_TARGETS = { + BLOCK: 'block', + BLOCKS: 'blocks', + EDGE: 'edge', + EDGES: 'edges', + SUBBLOCK: 'subblock', + SUBFLOW: 'subflow', + VARIABLE: 'variable', + WORKFLOW: 'workflow', +} as const + +export type OperationTarget = (typeof OPERATION_TARGETS)[keyof typeof OPERATION_TARGETS] + +/** Undo/Redo operation types (includes some socket operations + undo-specific ones) */ +export const UNDO_REDO_OPERATIONS = { + BATCH_ADD_BLOCKS: 'batch-add-blocks', + BATCH_REMOVE_BLOCKS: 'batch-remove-blocks', + BATCH_ADD_EDGES: 'batch-add-edges', + BATCH_REMOVE_EDGES: 'batch-remove-edges', + BATCH_MOVE_BLOCKS: 'batch-move-blocks', + UPDATE_PARENT: 'update-parent', + BATCH_UPDATE_PARENT: 'batch-update-parent', + BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', + BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', + APPLY_DIFF: 'apply-diff', + ACCEPT_DIFF: 'accept-diff', + REJECT_DIFF: 'reject-diff', +} as const + +export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS] diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 014cf08a63..59e73ef605 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -7,6 +7,16 @@ import postgres from 'postgres' import { env } from '@/lib/core/config/env' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' const logger = createLogger('SocketDatabase') @@ -155,7 +165,7 @@ export async function persistWorkflowOperation(workflowId: string, operation: an try { const { operation: op, target, payload, timestamp, userId } = operation - if (op === 'update-position' && Math.random() < 0.01) { + if (op === BLOCK_OPERATIONS.UPDATE_POSITION && Math.random() < 0.01) { logger.debug('Socket DB operation sample:', { operation: op, target, @@ -170,22 +180,25 @@ export async function persistWorkflowOperation(workflowId: string, operation: an .where(eq(workflow.id, workflowId)) switch (target) { - case 'block': + case OPERATION_TARGETS.BLOCK: await handleBlockOperationTx(tx, workflowId, op, payload) break - case 'blocks': + case OPERATION_TARGETS.BLOCKS: await handleBlocksOperationTx(tx, workflowId, op, payload) break - case 'edge': + case OPERATION_TARGETS.EDGE: await handleEdgeOperationTx(tx, workflowId, op, payload) break - case 'subflow': + case OPERATION_TARGETS.EDGES: + await handleEdgesOperationTx(tx, workflowId, op, payload) + break + case OPERATION_TARGETS.SUBFLOW: await handleSubflowOperationTx(tx, workflowId, op, payload) break - case 'variable': + case OPERATION_TARGETS.VARIABLE: await handleVariableOperationTx(tx, workflowId, op, payload) break - case 'workflow': + case OPERATION_TARGETS.WORKFLOW: await handleWorkflowOperationTx(tx, workflowId, op, payload) break default: @@ -219,7 +232,7 @@ async function handleBlockOperationTx( payload: any ) { switch (operation) { - case 'update-position': { + case BLOCK_OPERATIONS.UPDATE_POSITION: { if (!payload.id || !payload.position) { throw new Error('Missing required fields for update position operation') } @@ -244,7 +257,7 @@ async function handleBlockOperationTx( break } - case 'update-name': { + case BLOCK_OPERATIONS.UPDATE_NAME: { if (!payload.id || !payload.name) { throw new Error('Missing required fields for update name operation') } @@ -266,7 +279,7 @@ async function handleBlockOperationTx( break } - case 'toggle-enabled': { + case BLOCK_OPERATIONS.TOGGLE_ENABLED: { if (!payload.id) { throw new Error('Missing block ID for toggle enabled operation') } @@ -296,7 +309,7 @@ async function handleBlockOperationTx( break } - case 'update-parent': { + case BLOCK_OPERATIONS.UPDATE_PARENT: { if (!payload.id) { throw new Error('Missing block ID for update parent operation') } @@ -361,7 +374,7 @@ async function handleBlockOperationTx( break } - case 'update-advanced-mode': { + case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: { if (!payload.id || payload.advancedMode === undefined) { throw new Error('Missing required fields for update advanced mode operation') } @@ -383,7 +396,7 @@ async function handleBlockOperationTx( break } - case 'update-trigger-mode': { + case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: { if (!payload.id || payload.triggerMode === undefined) { throw new Error('Missing required fields for update trigger mode operation') } @@ -405,7 +418,7 @@ async function handleBlockOperationTx( break } - case 'toggle-handles': { + case BLOCK_OPERATIONS.TOGGLE_HANDLES: { if (!payload.id || payload.horizontalHandles === undefined) { throw new Error('Missing required fields for toggle handles operation') } @@ -442,7 +455,7 @@ async function handleBlocksOperationTx( payload: any ) { switch (operation) { - case 'batch-update-positions': { + case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: { const { updates } = payload if (!Array.isArray(updates) || updates.length === 0) { return @@ -463,7 +476,7 @@ async function handleBlocksOperationTx( break } - case 'batch-add-blocks': { + case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { const { blocks, edges, loops, parallels } = payload logger.info(`Batch adding blocks to workflow ${workflowId}`, { @@ -575,7 +588,7 @@ async function handleBlocksOperationTx( break } - case 'batch-remove-blocks': { + case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { const { ids } = payload if (!Array.isArray(ids) || ids.length === 0) { return @@ -690,6 +703,135 @@ async function handleBlocksOperationTx( break } + case BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED: { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info( + `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` + ) + + const blocks = await tx + .select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) + + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + enabled: !block.enabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) + } + + logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`) + break + } + + case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) + + const blocks = await tx + .select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) + + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + horizontalHandles: !block.horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) + } + + logger.debug(`Batch toggled handles for ${blocks.length} blocks`) + break + } + + case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { + const { updates } = payload + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`) + + for (const update of updates) { + const { id, parentId, position } = update + if (!id) continue + + // Fetch current parent to update subflow node lists + const [existing] = await tx + .select({ + id: workflowBlocks.id, + parentId: sql`${workflowBlocks.data}->>'parentId'`, + }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!existing) { + logger.warn(`Block ${id} not found for batch-update-parent`) + continue + } + + const isRemovingFromParent = !parentId + + // Get current data and position + const [currentBlock] = await tx + .select({ + data: workflowBlocks.data, + positionX: workflowBlocks.positionX, + positionY: workflowBlocks.positionY, + }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + const currentData = currentBlock?.data || {} + + const updatedData = isRemovingFromParent + ? {} + : { + ...currentData, + ...(parentId ? { parentId, extent: 'parent' } : {}), + } + + await tx + .update(workflowBlocks) + .set({ + positionX: position?.x ?? currentBlock?.positionX ?? 0, + positionY: position?.y ?? currentBlock?.positionY ?? 0, + data: updatedData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + + // If the block now has a parent, update the new parent's subflow node list + if (parentId) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + // If the block had a previous parent, update that parent's node list as well + if (existing?.parentId && existing.parentId !== parentId) { + await updateSubflowNodeList(tx, workflowId, existing.parentId) + } + } + + logger.debug(`Batch updated parent for ${updates.length} blocks`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } @@ -697,7 +839,7 @@ async function handleBlocksOperationTx( async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) { switch (operation) { - case 'add': { + case EDGE_OPERATIONS.ADD: { // Validate required fields if (!payload.id || !payload.source || !payload.target) { throw new Error('Missing required fields for add edge operation') @@ -716,7 +858,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } - case 'remove': { + case EDGE_OPERATIONS.REMOVE: { if (!payload.id) { throw new Error('Missing edge ID for remove operation') } @@ -740,6 +882,60 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } +async function handleEdgesOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { + const { ids } = payload + if (!Array.isArray(ids) || ids.length === 0) { + logger.debug('No edge IDs provided for batch remove') + return + } + + logger.info(`Batch removing ${ids.length} edges from workflow ${workflowId}`) + + await tx + .delete(workflowEdges) + .where(and(eq(workflowEdges.workflowId, workflowId), inArray(workflowEdges.id, ids))) + + logger.debug(`Batch removed ${ids.length} edges from workflow ${workflowId}`) + break + } + + case EDGES_OPERATIONS.BATCH_ADD_EDGES: { + const { edges } = payload + if (!Array.isArray(edges) || edges.length === 0) { + logger.debug('No edges provided for batch add') + return + } + + logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) + + const edgeValues = edges.map((edge: Record) => ({ + id: edge.id as string, + workflowId, + sourceBlockId: edge.source as string, + targetBlockId: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) || null, + targetHandle: (edge.targetHandle as string | null) || null, + })) + + await tx.insert(workflowEdges).values(edgeValues) + + logger.debug(`Batch added ${edges.length} edges to workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown edges operation: ${operation}`) + throw new Error(`Unsupported edges operation: ${operation}`) + } +} + async function handleSubflowOperationTx( tx: any, workflowId: string, @@ -747,7 +943,7 @@ async function handleSubflowOperationTx( payload: any ) { switch (operation) { - case 'update': { + case SUBFLOW_OPERATIONS.UPDATE: { if (!payload.id || !payload.config) { throw new Error('Missing required fields for update subflow operation') } @@ -874,7 +1070,7 @@ async function handleVariableOperationTx( const currentVariables = (workflowData[0].variables as Record) || {} switch (operation) { - case 'add': { + case VARIABLE_OPERATIONS.ADD: { if (!payload.id || !payload.name || payload.type === undefined) { throw new Error('Missing required fields for add variable operation') } @@ -903,7 +1099,7 @@ async function handleVariableOperationTx( break } - case 'remove': { + case VARIABLE_OPERATIONS.REMOVE: { if (!payload.variableId) { throw new Error('Missing variable ID for remove operation') } @@ -937,7 +1133,7 @@ async function handleWorkflowOperationTx( payload: any ) { switch (operation) { - case 'replace-state': { + case WORKFLOW_OPERATIONS.REPLACE_STATE: { if (!payload.state) { throw new Error('Missing state for replace-state operation') } diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 7fd64995c9..9b74293bbf 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -1,5 +1,14 @@ import { createLogger } from '@sim/logger' import { ZodError } from 'zod' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + VARIABLE_OPERATIONS, + type VariableOperation, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' import { persistWorkflowOperation } from '@/socket/database/operations' import type { HandlerDependencies } from '@/socket/handlers/workflow' import type { AuthenticatedSocket } from '@/socket/middleware/auth' @@ -45,7 +54,8 @@ export function setupOperationsHandlers( // For position updates, preserve client timestamp to maintain ordering // For other operations, use server timestamp for consistency - const isPositionUpdate = operation === 'update-position' && target === 'block' + const isPositionUpdate = + operation === BLOCK_OPERATIONS.UPDATE_POSITION && target === OPERATION_TARGETS.BLOCK const commitPositionUpdate = isPositionUpdate && 'commit' in payload ? payload.commit === true : false const operationTimestamp = isPositionUpdate ? timestamp : Date.now() @@ -145,7 +155,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-update-positions') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS + ) { socket.to(workflowId).emit('workflow-operation', { operation, target, @@ -184,8 +197,12 @@ export function setupOperationsHandlers( return } - if (target === 'variable' && ['add', 'remove'].includes(operation)) { - // Persist first, then broadcast + if ( + target === OPERATION_TARGETS.VARIABLE && + ([VARIABLE_OPERATIONS.ADD, VARIABLE_OPERATIONS.REMOVE] as VariableOperation[]).includes( + operation as VariableOperation + ) + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -222,7 +239,10 @@ export function setupOperationsHandlers( return } - if (target === 'workflow' && operation === 'replace-state') { + if ( + target === OPERATION_TARGETS.WORKFLOW && + operation === WORKFLOW_OPERATIONS.REPLACE_STATE + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -259,7 +279,164 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-add-blocks') { + if (target === OPERATION_TARGETS.BLOCKS && operation === BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS + ) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_REMOVE_EDGES) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED + ) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES + ) { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -288,7 +465,7 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-remove-blocks') { + if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) { await persistWorkflowOperation(workflowId, { operation, target, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 02aadfdde8..5772142e20 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -16,6 +16,11 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', @@ -33,6 +38,11 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 71be529774..85499b0c5b 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -1,11 +1,20 @@ import { z } from 'zod' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' const PositionSchema = z.object({ x: z.number(), y: z.number(), }) -// Schema for auto-connect edge data const AutoConnectEdgeSchema = z.object({ id: z.string(), source: z.string(), @@ -17,16 +26,16 @@ const AutoConnectEdgeSchema = z.object({ export const BlockOperationSchema = z.object({ operation: z.enum([ - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'update-wide', - 'update-advanced-mode', - 'update-trigger-mode', - 'toggle-handles', + BLOCK_OPERATIONS.UPDATE_POSITION, + BLOCK_OPERATIONS.UPDATE_NAME, + BLOCK_OPERATIONS.TOGGLE_ENABLED, + BLOCK_OPERATIONS.UPDATE_PARENT, + BLOCK_OPERATIONS.UPDATE_WIDE, + BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, + BLOCK_OPERATIONS.TOGGLE_HANDLES, ]), - target: z.literal('block'), + target: z.literal(OPERATION_TARGETS.BLOCK), payload: z.object({ id: z.string(), type: z.string().optional(), @@ -49,8 +58,8 @@ export const BlockOperationSchema = z.object({ }) export const BatchPositionUpdateSchema = z.object({ - operation: z.literal('batch-update-positions'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ updates: z.array( z.object({ @@ -64,8 +73,8 @@ export const BatchPositionUpdateSchema = z.object({ }) export const EdgeOperationSchema = z.object({ - operation: z.enum(['add', 'remove']), - target: z.literal('edge'), + operation: z.enum([EDGE_OPERATIONS.ADD, EDGE_OPERATIONS.REMOVE]), + target: z.literal(OPERATION_TARGETS.EDGE), payload: z.object({ id: z.string(), source: z.string().optional(), @@ -78,8 +87,8 @@ export const EdgeOperationSchema = z.object({ }) export const SubflowOperationSchema = z.object({ - operation: z.enum(['add', 'remove', 'update']), - target: z.literal('subflow'), + operation: z.enum([SUBFLOW_OPERATIONS.ADD, SUBFLOW_OPERATIONS.REMOVE, SUBFLOW_OPERATIONS.UPDATE]), + target: z.literal(OPERATION_TARGETS.SUBFLOW), payload: z.object({ id: z.string(), type: z.enum(['loop', 'parallel']).optional(), @@ -91,8 +100,8 @@ export const SubflowOperationSchema = z.object({ export const VariableOperationSchema = z.union([ z.object({ - operation: z.literal('add'), - target: z.literal('variable'), + operation: z.literal(VARIABLE_OPERATIONS.ADD), + target: z.literal(OPERATION_TARGETS.VARIABLE), payload: z.object({ id: z.string(), name: z.string(), @@ -104,8 +113,8 @@ export const VariableOperationSchema = z.union([ operationId: z.string().optional(), }), z.object({ - operation: z.literal('remove'), - target: z.literal('variable'), + operation: z.literal(VARIABLE_OPERATIONS.REMOVE), + target: z.literal(OPERATION_TARGETS.VARIABLE), payload: z.object({ variableId: z.string(), }), @@ -115,8 +124,8 @@ export const VariableOperationSchema = z.union([ ]) export const WorkflowStateOperationSchema = z.object({ - operation: z.literal('replace-state'), - target: z.literal('workflow'), + operation: z.literal(WORKFLOW_OPERATIONS.REPLACE_STATE), + target: z.literal(OPERATION_TARGETS.WORKFLOW), payload: z.object({ state: z.any(), }), @@ -125,8 +134,8 @@ export const WorkflowStateOperationSchema = z.object({ }) export const BatchAddBlocksSchema = z.object({ - operation: z.literal('batch-add-blocks'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ blocks: z.array(z.record(z.any())), edges: z.array(AutoConnectEdgeSchema).optional(), @@ -139,8 +148,8 @@ export const BatchAddBlocksSchema = z.object({ }) export const BatchRemoveBlocksSchema = z.object({ - operation: z.literal('batch-remove-blocks'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ ids: z.array(z.string()), }), @@ -148,12 +157,83 @@ export const BatchRemoveBlocksSchema = z.object({ operationId: z.string().optional(), }) +export const BatchRemoveEdgesSchema = z.object({ + operation: z.literal(EDGES_OPERATIONS.BATCH_REMOVE_EDGES), + target: z.literal(OPERATION_TARGETS.EDGES), + payload: z.object({ + ids: z.array(z.string()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchAddEdgesSchema = z.object({ + operation: z.literal(EDGES_OPERATIONS.BATCH_ADD_EDGES), + target: z.literal(OPERATION_TARGETS.EDGES), + payload: z.object({ + edges: z.array( + z.object({ + id: z.string(), + source: z.string(), + target: z.string(), + sourceHandle: z.string().nullable().optional(), + targetHandle: z.string().nullable().optional(), + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleEnabledSchema = z.object({ + operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED), + target: z.literal(OPERATION_TARGETS.BLOCKS), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleHandlesSchema = z.object({ + operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES), + target: z.literal(OPERATION_TARGETS.BLOCKS), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchUpdateParentSchema = z.object({ + operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT), + target: z.literal(OPERATION_TARGETS.BLOCKS), + payload: z.object({ + updates: z.array( + z.object({ + id: z.string(), + parentId: z.string(), + position: PositionSchema, + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, BatchAddBlocksSchema, BatchRemoveBlocksSchema, + BatchToggleEnabledSchema, + BatchToggleHandlesSchema, + BatchUpdateParentSchema, EdgeOperationSchema, + BatchAddEdgesSchema, + BatchRemoveEdgesSchema, SubflowOperationSchema, VariableOperationSchema, WorkflowStateOperationSchema, diff --git a/apps/sim/stores/panel/variables/types.ts b/apps/sim/stores/panel/variables/types.ts index 5910168e16..c0f7d06d15 100644 --- a/apps/sim/stores/panel/variables/types.ts +++ b/apps/sim/stores/panel/variables/types.ts @@ -13,7 +13,7 @@ export interface Variable { workflowId: string name: string // Must be unique per workflow type: VariableType - value: any + value: unknown validationError?: string // Tracks format validation errors } diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index 99f6f3827a..add8625961 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -13,11 +13,11 @@ import { createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, createBlock, createMockStorage, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -596,23 +596,24 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(2) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-add-edges') expect(getStackSizes(workflowId, userId).redoSize).toBe(1) redo(workflowId, userId) expect(getStackSizes(workflowId, userId).undoSize).toBe(2) }) - it('should handle remove-edge operations', () => { + it('should handle batch-remove-edges operations', () => { const { push, undo, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) + const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(1) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('remove-edge') - expect(entry?.inverse.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-remove-edges') + expect(entry?.inverse.type).toBe('batch-add-edges') }) }) @@ -672,8 +673,10 @@ describe('useUndoRedoStore', () => { it('should remove entries for non-existent edges', () => { const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) - push(workflowId, userId, createRemoveEdgeEntry('edge-2', null, { workflowId, userId })) + const edge1 = { id: 'edge-1', source: 'a', target: 'b' } + const edge2 = { id: 'edge-2', source: 'c', target: 'd' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId })) + push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(2) @@ -686,6 +689,8 @@ describe('useUndoRedoStore', () => { pruneInvalidEntries(workflowId, userId, graph as any) + // edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned + // edge-2 doesn't exist, so we can undo its removal (can add it back) → kept expect(getStackSizes(workflowId, userId).undoSize).toBe(1) }) }) @@ -751,7 +756,7 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(3) const moveEntry = undo(workflowId, userId) - expect(moveEntry?.operation.type).toBe('move-block') + expect(moveEntry?.operation.type).toBe('batch-move-blocks') const parentEntry = undo(workflowId, userId) expect(parentEntry?.operation.type).toBe('update-parent') diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 07c67a0f5b..6776bf66e1 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -2,13 +2,16 @@ import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' +import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, - MoveBlockOperation, + BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, - RemoveEdgeOperation, UndoRedoState, } from '@/stores/undo-redo/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -84,36 +87,33 @@ function isOperationApplicable( graph: { blocksById: Record; edgesById: Record } ): boolean { switch (operation.type) { - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const op = operation as BatchRemoveBlocksOperation return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id])) } - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const op = operation as BatchAddBlocksOperation return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id]) } - case 'move-block': { - const op = operation as MoveBlockOperation - return Boolean(graph.blocksById[op.data.blockId]) + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { + const op = operation as BatchMoveBlocksOperation + return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId])) } - case 'update-parent': { + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } - case 'remove-edge': { - const op = operation as RemoveEdgeOperation - return Boolean(graph.edgesById[op.data.edgeId]) + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { + const op = operation as BatchUpdateParentOperation + return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId])) } - case 'add-edge': { - const edgeId = operation.data.edgeId - return !graph.edgesById[edgeId] + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { + const op = operation as BatchRemoveEdgesOperation + return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } - case 'add-subflow': - case 'remove-subflow': { - const subflowId = operation.data.subflowId - return operation.type === 'remove-subflow' - ? Boolean(graph.blocksById[subflowId]) - : !graph.blocksById[subflowId] + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { + const op = operation as BatchAddEdgesOperation + return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) } default: return true @@ -198,62 +198,82 @@ export const useUndoRedoStore = create()( } } - // Coalesce consecutive move-block operations for the same block - if (entry.operation.type === 'move-block') { - const incoming = entry.operation as MoveBlockOperation + // Coalesce consecutive batch-move-blocks operations for overlapping blocks + if (entry.operation.type === 'batch-move-blocks') { + const incoming = entry.operation as BatchMoveBlocksOperation const last = stack.undo[stack.undo.length - 1] - // Skip no-op moves - const b1 = incoming.data.before - const a1 = incoming.data.after - const sameParent = (b1.parentId ?? null) === (a1.parentId ?? null) - if (b1.x === a1.x && b1.y === a1.y && sameParent) { - logger.debug('Skipped no-op move push') + // Skip no-op moves (all moves have same before/after) + const allNoOp = incoming.data.moves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return move.before.x === move.after.x && move.before.y === move.after.y && sameParent + }) + if (allNoOp) { + logger.debug('Skipped no-op batch move push') return } - if (last && last.operation.type === 'move-block' && last.inverse.type === 'move-block') { - const prev = last.operation as MoveBlockOperation - if (prev.data.blockId === incoming.data.blockId) { - // Merge: keep earliest before, latest after - const mergedBefore = prev.data.before - const mergedAfter = incoming.data.after + if ( + last && + last.operation.type === 'batch-move-blocks' && + last.inverse.type === 'batch-move-blocks' + ) { + const prev = last.operation as BatchMoveBlocksOperation + const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId)) + const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId)) + + // Check if same set of blocks + const sameBlocks = + prevBlockIds.size === incomingBlockIds.size && + [...prevBlockIds].every((id) => incomingBlockIds.has(id)) + + if (sameBlocks) { + // Merge: keep earliest before, latest after for each block + const mergedMoves = incoming.data.moves.map((incomingMove) => { + const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)! + return { + blockId: incomingMove.blockId, + before: prevMove.before, + after: incomingMove.after, + } + }) - const sameAfter = - mergedBefore.x === mergedAfter.x && - mergedBefore.y === mergedAfter.y && - (mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null) + // Check if all moves result in same position (net no-op) + const allSameAfter = mergedMoves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return ( + move.before.x === move.after.x && move.before.y === move.after.y && sameParent + ) + }) - const newUndoCoalesced: OperationEntry[] = sameAfter + const newUndoCoalesced: OperationEntry[] = allSameAfter ? stack.undo.slice(0, -1) : (() => { - const op = entry.operation as MoveBlockOperation - const inv = entry.inverse as MoveBlockOperation + const op = entry.operation as BatchMoveBlocksOperation + const inv = entry.inverse as BatchMoveBlocksOperation const newEntry: OperationEntry = { id: entry.id, createdAt: entry.createdAt, operation: { id: op.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: op.timestamp, workflowId, userId, - data: { - blockId: incoming.data.blockId, - before: mergedBefore, - after: mergedAfter, - }, + data: { moves: mergedMoves }, }, inverse: { id: inv.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: inv.timestamp, workflowId, userId, data: { - blockId: incoming.data.blockId, - before: mergedAfter, - after: mergedBefore, + moves: mergedMoves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, }, } @@ -268,10 +288,10 @@ export const useUndoRedoStore = create()( set({ stacks: currentStacks }) - logger.debug('Coalesced consecutive move operations', { + logger.debug('Coalesced consecutive batch move operations', { workflowId, userId, - blockId: incoming.data.blockId, + blockCount: mergedMoves.length, undoSize: newUndoCoalesced.length, }) return diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index d69688e838..f68aa66e68 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -1,19 +1,8 @@ import type { Edge } from 'reactflow' +import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@/socket/constants' import type { BlockState } from '@/stores/workflows/workflow/types' -export type OperationType = - | 'batch-add-blocks' - | 'batch-remove-blocks' - | 'add-edge' - | 'remove-edge' - | 'add-subflow' - | 'remove-subflow' - | 'move-block' - | 'move-subflow' - | 'update-parent' - | 'apply-diff' - | 'accept-diff' - | 'reject-diff' +export type OperationType = UndoRedoOperation export interface BaseOperation { id: string @@ -24,7 +13,7 @@ export interface BaseOperation { } export interface BatchAddBlocksOperation extends BaseOperation { - type: 'batch-add-blocks' + type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS data: { blockSnapshots: BlockState[] edgeSnapshots: Edge[] @@ -33,7 +22,7 @@ export interface BatchAddBlocksOperation extends BaseOperation { } export interface BatchRemoveBlocksOperation extends BaseOperation { - type: 'batch-remove-blocks' + type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS data: { blockSnapshots: BlockState[] edgeSnapshots: Edge[] @@ -41,82 +30,75 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } } -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' +export interface BatchAddEdgesOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES data: { - edgeId: string + edgeSnapshots: Edge[] } } -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES data: { - edgeId: string - edgeSnapshot: Edge | null + edgeSnapshots: Edge[] } } -export interface AddSubflowOperation extends BaseOperation { - type: 'add-subflow' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS data: { - subflowId: string + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } -export interface RemoveSubflowOperation extends BaseOperation { - type: 'remove-subflow' +export interface UpdateParentOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.UPDATE_PARENT data: { - subflowId: string - subflowSnapshot: BlockState | null + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] } } -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchUpdateParentOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT data: { - blockId: string - before: { - x: number - y: number - parentId?: string - } - after: { - x: number - y: number - parentId?: string - } + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> } } -export interface MoveSubflowOperation extends BaseOperation { - type: 'move-subflow' +export interface BatchToggleEnabledOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED data: { - subflowId: string - before: { - x: number - y: number - } - after: { - x: number - y: number - } + blockIds: string[] + previousStates: Record } } -export interface UpdateParentOperation extends BaseOperation { - type: 'update-parent' +export interface BatchToggleHandlesOperation extends BaseOperation { + type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES data: { - blockId: string - oldParentId?: string - newParentId?: string - oldPosition: { x: number; y: number } - newPosition: { x: number; y: number } - affectedEdges?: Edge[] + blockIds: string[] + previousStates: Record } } export interface ApplyDiffOperation extends BaseOperation { - type: 'apply-diff' + type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF data: { baselineSnapshot: any // WorkflowState snapshot before diff proposedState: any // WorkflowState with diff applied @@ -125,7 +107,7 @@ export interface ApplyDiffOperation extends BaseOperation { } export interface AcceptDiffOperation extends BaseOperation { - type: 'accept-diff' + type: typeof UNDO_REDO_OPERATIONS.ACCEPT_DIFF data: { beforeAccept: any // WorkflowState with diff markers afterAccept: any // WorkflowState without diff markers @@ -135,7 +117,7 @@ export interface AcceptDiffOperation extends BaseOperation { } export interface RejectDiffOperation extends BaseOperation { - type: 'reject-diff' + type: typeof UNDO_REDO_OPERATIONS.REJECT_DIFF data: { beforeReject: any // WorkflowState with diff markers afterReject: any // WorkflowState baseline (after reject) @@ -147,13 +129,13 @@ export interface RejectDiffOperation extends BaseOperation { export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation - | RemoveEdgeOperation - | AddSubflowOperation - | RemoveSubflowOperation - | MoveBlockOperation - | MoveSubflowOperation + | BatchAddEdgesOperation + | BatchRemoveEdgesOperation + | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation + | BatchToggleEnabledOperation + | BatchToggleHandlesOperation | ApplyDiffOperation | AcceptDiffOperation | RejectDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index e00209c203..e747c2fd2d 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,6 +1,11 @@ +import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, + BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -16,11 +21,11 @@ export function createOperationEntry(operation: Operation, inverse: Operation): export function createInverseOperation(operation: Operation): Operation { switch (operation.type) { - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const op = operation as BatchAddBlocksOperation return { ...operation, - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, data: { blockSnapshots: op.data.blockSnapshots, edgeSnapshots: op.data.edgeSnapshots, @@ -29,11 +34,11 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchRemoveBlocksOperation } - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const op = operation as BatchRemoveBlocksOperation return { ...operation, - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, data: { blockSnapshots: op.data.blockSnapshots, edgeSnapshots: op.data.edgeSnapshots, @@ -42,65 +47,44 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddBlocksOperation } - case 'add-edge': + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { + const op = operation as BatchAddEdgesOperation return { ...operation, - type: 'remove-edge', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, data: { - edgeId: operation.data.edgeId, - edgeSnapshot: null, - }, - } - - case 'remove-edge': - return { - ...operation, - type: 'add-edge', - data: { - edgeId: operation.data.edgeId, - }, - } - - case 'add-subflow': - return { - ...operation, - type: 'remove-subflow', - data: { - subflowId: operation.data.subflowId, - subflowSnapshot: null, - }, - } - - case 'remove-subflow': - return { - ...operation, - type: 'add-subflow', - data: { - subflowId: operation.data.subflowId, + edgeSnapshots: op.data.edgeSnapshots, }, - } + } as BatchRemoveEdgesOperation + } - case 'move-block': + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { + const op = operation as BatchRemoveEdgesOperation return { ...operation, + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, data: { - blockId: operation.data.blockId, - before: operation.data.after, - after: operation.data.before, + edgeSnapshots: op.data.edgeSnapshots, }, - } + } as BatchAddEdgesOperation + } - case 'move-subflow': + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { + const op = operation as BatchMoveBlocksOperation return { ...operation, + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, data: { - subflowId: operation.data.subflowId, - before: operation.data.after, - after: operation.data.before, + moves: op.data.moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, - } + } as BatchMoveBlocksOperation + } - case 'update-parent': + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: return { ...operation, data: { @@ -113,7 +97,24 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'apply-diff': + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { + const op = operation as BatchUpdateParentOperation + return { + ...operation, + data: { + updates: op.data.updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } as BatchUpdateParentOperation + } + + case UNDO_REDO_OPERATIONS.APPLY_DIFF: return { ...operation, data: { @@ -123,7 +124,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'accept-diff': + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: return { ...operation, data: { @@ -134,7 +135,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'reject-diff': + case UNDO_REDO_OPERATIONS.REJECT_DIFF: return { ...operation, data: { @@ -145,130 +146,21 @@ export function createInverseOperation(operation: Operation): Operation { }, } - default: { - const exhaustiveCheck: never = operation - throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) - } - } -} - -export function operationToCollaborativePayload(operation: Operation): { - operation: string - target: string - payload: Record -} { - switch (operation.type) { - case 'batch-add-blocks': { - const op = operation as BatchAddBlocksOperation + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: return { - operation: 'batch-add-blocks', - target: 'blocks', - payload: { - blocks: op.data.blockSnapshots, - edges: op.data.edgeSnapshots, - loops: {}, - parallels: {}, - subBlockValues: op.data.subBlockValues, - }, - } - } - - case 'batch-remove-blocks': { - const op = operation as BatchRemoveBlocksOperation - return { - operation: 'batch-remove-blocks', - target: 'blocks', - payload: { ids: op.data.blockSnapshots.map((b) => b.id) }, - } - } - - case 'add-edge': - return { - operation: 'add', - target: 'edge', - payload: { id: operation.data.edgeId }, - } - - case 'remove-edge': - return { - operation: 'remove', - target: 'edge', - payload: { id: operation.data.edgeId }, - } - - case 'add-subflow': - return { - operation: 'add', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - - case 'remove-subflow': - return { - operation: 'remove', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - - case 'move-block': - return { - operation: 'update-position', - target: 'block', - payload: { - id: operation.data.blockId, - x: operation.data.after.x, - y: operation.data.after.y, - parentId: operation.data.after.parentId, - }, - } - - case 'move-subflow': - return { - operation: 'update-position', - target: 'subflow', - payload: { - id: operation.data.subflowId, - x: operation.data.after.x, - y: operation.data.after.y, - }, - } - - case 'update-parent': - return { - operation: 'update-parent', - target: 'block', - payload: { - id: operation.data.blockId, - parentId: operation.data.newParentId, - x: operation.data.newPosition.x, - y: operation.data.newPosition.y, - }, - } - - case 'apply-diff': - return { - operation: 'apply-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'accept-diff': - return { - operation: 'accept-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, }, } - case 'reject-diff': + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: return { - operation: 'reject-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, }, } diff --git a/apps/sim/stores/workflows/subblock/types.ts b/apps/sim/stores/workflows/subblock/types.ts index 243e12bf01..25004313ba 100644 --- a/apps/sim/stores/workflows/subblock/types.ts +++ b/apps/sim/stores/workflows/subblock/types.ts @@ -1,13 +1,25 @@ -export interface SubBlockState { - workflowValues: Record>> // Store values per workflow ID +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Value type for subblock values. + * Uses unknown to support various value types that subblocks can store, + * including strings, numbers, arrays, objects, and other complex structures. + */ +export type SubBlockValue = unknown + +export interface SubBlockStoreState { + workflowValues: Record>> // Store values per workflow ID loadingWebhooks: Set // Track which blockIds are currently loading webhooks checkedWebhooks: Set // Track which blockIds have been checked for webhooks } -export interface SubBlockStore extends SubBlockState { - setValue: (blockId: string, subBlockId: string, value: any) => void - getValue: (blockId: string, subBlockId: string) => any +export interface SubBlockStore extends SubBlockStoreState { + setValue: (blockId: string, subBlockId: string, value: SubBlockValue) => void + getValue: (blockId: string, subBlockId: string) => SubBlockValue | undefined clear: () => void - initializeFromWorkflow: (workflowId: string, blocks: Record) => void - setWorkflowValues: (workflowId: string, values: Record>) => void + initializeFromWorkflow: (workflowId: string, blocks: Record) => void + setWorkflowValues: ( + workflowId: string, + values: Record> + ) => void } diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 2eb2c4618a..ac0f529870 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -271,7 +271,9 @@ export function mergeSubblockState( subAcc[subBlockId] = { ...subBlock, - value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value, + value: (storedValue !== undefined && storedValue !== null + ? storedValue + : subBlock.value) as SubBlockState['value'], } return subAcc @@ -288,7 +290,7 @@ export function mergeSubblockState( mergedSubBlocks[subBlockId] = { id: subBlockId, type: 'short-input', // Default type that's safe to use - value: value, + value: value as SubBlockState['value'], } } }) @@ -353,8 +355,9 @@ export async function mergeSubblockStateAsync( subBlockId, { ...subBlock, - value: - storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value, + value: (storedValue !== undefined && storedValue !== null + ? storedValue + : subBlock.value) as SubBlockState['value'], }, ] as const }) @@ -376,7 +379,7 @@ export async function mergeSubblockStateAsync( mergedSubBlocks[subBlockId] = { id: subBlockId, type: 'short-input', - value: value, + value: value as SubBlockState['value'], } } }) @@ -425,14 +428,8 @@ function updateBlockReferences( clearTriggerRuntimeValues = false ): void { Object.entries(blocks).forEach(([_, block]) => { - if (block.data?.parentId) { - const newParentId = idMap.get(block.data.parentId) - if (newParentId) { - block.data = { ...block.data, parentId: newParentId } - } else { - block.data = { ...block.data, parentId: undefined, extent: undefined } - } - } + // NOTE: parentId remapping is handled in regenerateBlockIds' second pass. + // Do NOT remap parentId here as it would incorrectly clear already-mapped IDs. if (block.subBlocks) { Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { @@ -462,6 +459,7 @@ export function regenerateWorkflowIds( const nameMap = new Map() const newBlocks: Record = {} + // First pass: generate new IDs Object.entries(workflowState.blocks).forEach(([oldId, block]) => { const newId = uuidv4() blockIdMap.set(oldId, newId) @@ -470,6 +468,19 @@ export function regenerateWorkflowIds( newBlocks[newId] = { ...block, id: newId } }) + // Second pass: update parentId references + Object.values(newBlocks).forEach((block) => { + if (block.data?.parentId) { + const newParentId = blockIdMap.get(block.data.parentId) + if (newParentId) { + block.data = { ...block.data, parentId: newParentId } + } else { + // Parent not in the workflow, clear the relationship + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + }) + const newEdges = workflowState.edges.map((edge) => ({ ...edge, id: uuidv4(), @@ -532,6 +543,7 @@ export function regenerateBlockIds( // Track all blocks for name uniqueness (existing + newly processed) const allBlocksForNaming = { ...existingBlockNames } + // First pass: generate new IDs and names for all blocks Object.entries(blocks).forEach(([oldId, block]) => { const newId = uuidv4() blockIdMap.set(oldId, newId) @@ -541,17 +553,22 @@ export function regenerateBlockIds( const newNormalizedName = normalizeName(newName) nameMap.set(oldNormalizedName, newNormalizedName) - const isNested = !!block.data?.parentId + // Check if this block has a parent that's also being copied + // If so, it's a nested block and should keep its relative position (no offset) + // Only top-level blocks (no parent in the paste set) get the position offset + const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId] + const newPosition = hasParentInPasteSet + ? { x: block.position.x, y: block.position.y } // Keep relative position + : { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y } + + // Placeholder block - we'll update parentId in second pass const newBlock: BlockState = { ...block, id: newId, name: newName, - position: isNested - ? block.position - : { - x: block.position.x + positionOffset.x, - y: block.position.y + positionOffset.y, - }, + position: newPosition, + // Temporarily keep data as-is, we'll fix parentId in second pass + data: block.data ? { ...block.data } : block.data, } newBlocks[newId] = newBlock @@ -563,6 +580,25 @@ export function regenerateBlockIds( } }) + // Second pass: update parentId references for nested blocks + // If a block's parent is also being pasted, map to new parentId; otherwise clear it + Object.entries(newBlocks).forEach(([, block]) => { + if (block.data?.parentId) { + const oldParentId = block.data.parentId + const newParentId = blockIdMap.get(oldParentId) + + if (newParentId) { + block.data = { + ...block.data, + parentId: newParentId, + extent: 'parent', + } + } else { + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + }) + const newEdges = edges.map((edge) => ({ ...edge, id: uuidv4(), diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 41d3051637..36475ba462 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -174,6 +174,7 @@ export const useWorkflowStore = create()( ...data, ...(parentId && { parentId, extent: extent || 'parent' }), } + // #endregion const subBlocks: Record = {} const subBlockStore = useSubBlockStore.getState() @@ -295,26 +296,16 @@ export const useWorkflowStore = create()( return } - logger.info('UpdateParentId called:', { - blockId: id, - blockName: block.name, - blockType: block.type, - newParentId: parentId, - extent, - currentParentId: block.data?.parentId, - }) + if (parentId === id) { + logger.error('Blocked attempt to set block as its own parent', { blockId: id }) + return + } - // Skip if the parent ID hasn't changed if (block.data?.parentId === parentId) { - logger.info('Parent ID unchanged, skipping update') return } - // Store current absolute position const absolutePosition = { ...block.position } - - // Handle empty or null parentId (removing from parent) - // On removal, clear the data JSON entirely per normalized DB contract const newData = !parentId ? {} : { @@ -323,8 +314,6 @@ export const useWorkflowStore = create()( extent, } - // For removal we already set data to {}; for setting a parent keep as-is - const newState = { blocks: { ...get().blocks, @@ -339,12 +328,6 @@ export const useWorkflowStore = create()( parallels: { ...get().parallels }, } - logger.info('[WorkflowStore/updateParentId] Updated parentId relationship:', { - blockId: id, - newParentId: parentId || 'None (removed parent)', - keepingPosition: absolutePosition, - }) - set(newState) get().updateLastSaved() // Note: Socket.IO handles real-time sync automatically @@ -586,6 +569,27 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockEnabled: (id: string, enabled: boolean) => { + const block = get().blocks[id] + if (!block || block.enabled === enabled) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + enabled, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: { ...get().parallels }, + } + + set(newState) + get().updateLastSaved() + }, + duplicateBlock: (id: string) => { const block = get().blocks[id] if (!block) return @@ -668,6 +672,26 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockHandles: (id: string, horizontalHandles: boolean) => { + const block = get().blocks[id] + if (!block || block.horizontalHandles === horizontalHandles) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + horizontalHandles, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + } + + set(newState) + get().updateLastSaved() + }, + updateBlockName: (id: string, name: string) => { const oldBlock = get().blocks[id] if (!oldBlock) return { success: false, changedSubblocks: [] } diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index c836b8040c..97fcf033a8 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -1,5 +1,5 @@ import type { Edge } from 'reactflow' -import type { BlockOutput, SubBlockType } from '@/blocks/types' +import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types' import type { DeploymentStatus } from '@/stores/workflows/registry/types' export const SUBFLOW_TYPES = { @@ -17,14 +17,14 @@ export interface LoopConfig { nodes: string[] iterations: number loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record | string + forEachItems?: unknown[] | Record | string whileCondition?: string // JS expression that evaluates to boolean (for while loops) doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) } export interface ParallelConfig { nodes: string[] - distribution?: any[] | Record | string + distribution?: unknown[] | Record | string parallelType?: 'count' | 'collection' } @@ -76,7 +76,7 @@ export interface BlockState { name: string position: Position subBlocks: Record - outputs: Record + outputs: Record enabled: boolean horizontalHandles?: boolean height?: number @@ -137,6 +137,13 @@ export interface Parallel { parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs } +export interface Variable { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown +} + export interface DragStartPosition { id: string x: number @@ -156,12 +163,7 @@ export interface WorkflowState { description?: string exportedAt?: string } - variables?: Array<{ - id: string - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any - }> + variables?: Record isDeployed?: boolean deployedAt?: Date deploymentStatuses?: Record @@ -195,8 +197,10 @@ export interface WorkflowActions { clear: () => Partial updateLastSaved: () => void toggleBlockEnabled: (id: string) => void + setBlockEnabled: (id: string, enabled: boolean) => void duplicateBlock: (id: string) => void toggleBlockHandles: (id: string) => void + setBlockHandles: (id: string, horizontalHandles: boolean) => void updateBlockName: ( id: string, name: string diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 4a7f456b16..2fafe98625 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -120,24 +120,25 @@ export { } from './serialized-block.factory' // Undo/redo operation factories export { - type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, + type BatchAddEdgesOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, + type BatchUpdateParentOperation, createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, + createBatchUpdateParentEntry, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, - type MoveBlockOperation, type Operation, type OperationEntry, type OperationType, - type RemoveEdgeOperation, type UpdateParentOperation, } from './undo-redo.factory' -// User/workspace factories export { createUser, createUserWithWorkspace, @@ -147,7 +148,6 @@ export { type WorkflowObjectFactoryOptions, type WorkspaceFactoryOptions, } from './user.factory' -// Workflow factories export { createBranchingWorkflow, createLinearWorkflow, diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index d03c8cefe6..86c26ef927 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -8,10 +8,11 @@ import { nanoid } from 'nanoid' export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' - | 'add-edge' - | 'remove-edge' - | 'move-block' + | 'batch-add-edges' + | 'batch-remove-edges' + | 'batch-move-blocks' | 'update-parent' + | 'batch-update-parent' /** * Base operation interface. @@ -25,14 +26,16 @@ export interface BaseOperation { } /** - * Move block operation data. + * Batch move blocks operation data. */ -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: 'batch-move-blocks' data: { - blockId: string - before: { x: number; y: number; parentId?: string } - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } @@ -61,19 +64,19 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } /** - * Add edge operation data. + * Batch add edges operation data. */ -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' - data: { edgeId: string } +export interface BatchAddEdgesOperation extends BaseOperation { + type: 'batch-add-edges' + data: { edgeSnapshots: any[] } } /** - * Remove edge operation data. + * Batch remove edges operation data. */ -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' - data: { edgeId: string; edgeSnapshot: any } +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: 'batch-remove-edges' + data: { edgeSnapshots: any[] } } /** @@ -90,13 +93,28 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchUpdateParentOperation extends BaseOperation { + type: 'batch-update-parent' + data: { + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: any[] + }> + } +} + export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation - | RemoveEdgeOperation - | MoveBlockOperation + | BatchAddEdgesOperation + | BatchRemoveEdgesOperation + | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation /** * Operation entry with forward and inverse operations. @@ -208,40 +226,45 @@ export function createRemoveBlockEntry( } /** - * Creates a mock add-edge operation entry. + * Creates a mock batch-add-edges operation entry for a single edge. */ -export function createAddEdgeEntry(edgeId: string, options: OperationEntryOptions = {}): any { +export function createAddEdgeEntry( + edgeId: string, + edgeSnapshot: any = null, + options: OperationEntryOptions = {} +): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options const timestamp = Date.now() + const snapshot = edgeSnapshot || { id: edgeId, source: 'block-1', target: 'block-2' } + return { id, createdAt, operation: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [snapshot] }, }, inverse: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot: null }, + data: { edgeSnapshots: [snapshot] }, }, } } /** - * Creates a mock remove-edge operation entry. + * Creates a mock batch-remove-edges operation entry. */ -export function createRemoveEdgeEntry( - edgeId: string, - edgeSnapshot: any = null, +export function createBatchRemoveEdgesEntry( + edgeSnapshots: any[], options: OperationEntryOptions = {} ): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options @@ -252,19 +275,19 @@ export function createRemoveEdgeEntry( createdAt, operation: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot }, + data: { edgeSnapshots }, }, inverse: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots }, }, } } @@ -275,7 +298,7 @@ interface MoveBlockOptions extends OperationEntryOptions { } /** - * Creates a mock move-block operation entry. + * Creates a mock batch-move-blocks operation entry for a single block. */ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions = {}): any { const { @@ -293,19 +316,19 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions createdAt, operation: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before, after }, + data: { moves: [{ blockId, before, after }] }, }, inverse: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before: after, after: before }, + data: { moves: [{ blockId, before: after, after: before }] }, }, } } @@ -361,3 +384,75 @@ export function createUpdateParentEntry( }, } } + +interface BatchUpdateParentOptions extends OperationEntryOptions { + updates?: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition?: { x: number; y: number } + newPosition?: { x: number; y: number } + affectedEdges?: any[] + }> +} + +/** + * Creates a mock batch-update-parent operation entry. + */ +export function createBatchUpdateParentEntry(options: BatchUpdateParentOptions = {}): any { + const { + id = nanoid(8), + workflowId = 'wf-1', + userId = 'user-1', + createdAt = Date.now(), + updates = [ + { + blockId: 'block-1', + oldParentId: undefined, + newParentId: 'loop-1', + oldPosition: { x: 0, y: 0 }, + newPosition: { x: 50, y: 50 }, + }, + ], + } = options + const timestamp = Date.now() + + const processedUpdates = updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.oldParentId, + newParentId: u.newParentId, + oldPosition: u.oldPosition || { x: 0, y: 0 }, + newPosition: u.newPosition || { x: 50, y: 50 }, + affectedEdges: u.affectedEdges, + })) + + return { + id, + createdAt, + operation: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { updates: processedUpdates }, + }, + inverse: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { + updates: processedUpdates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + }, + } +}