diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx
new file mode 100644
index 00000000000..b84520a72cf
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow-cursor.test.tsx
@@ -0,0 +1,207 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { readFileSync } from 'node:fs'
+import { act, type ReactNode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { WorkflowState } from '@/stores/workflows/workflow/types'
+
+vi.mock('@xyflow/react', () => ({
+ ConnectionLineType: { SmoothStep: 'smoothstep' },
+ ReactFlowProvider: ({ children }: { children: ReactNode }) => children,
+ ReactFlow: () => (
+
+ ),
+ useReactFlow: () => ({ fitView: vi.fn() }),
+}))
+
+vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('@/hooks/queries/workflows', () => ({
+ useWorkflowMap: () => ({ data: {}, isSuccess: true, isPlaceholderData: false }),
+}))
+vi.mock('@sim/workflow-renderer', () => ({
+ BLOCK_DIMENSIONS: { FIXED_WIDTH: 200, MIN_HEIGHT: 100 },
+ BLOCK_Z_BASE: 1,
+ CANVAS_Z_INDEX_MODE: 'manual',
+ CONTAINER_CHILD_Z_BASE: 2,
+ CONTAINER_DIMENSIONS: {
+ MIN_WIDTH: 200,
+ MIN_HEIGHT: 100,
+ DEFAULT_WIDTH: 200,
+ DEFAULT_HEIGHT: 100,
+ },
+ EDGE_Z_BASE: 0,
+ EDGE_Z_MAX: 10,
+ getEdgeZIndexForTarget: () => 0,
+ sortNodesParentsFirst: (nodes: unknown[]) => nodes,
+ useCanvasColorMode: () => 'light',
+}))
+vi.mock('@sim/workflow-types/workflow', () => ({
+ normalizeWorkflowEdgeHandles: (edges: unknown[]) => edges,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge',
+ () => ({
+ WorkflowEdge: () => null,
+ })
+)
+vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils', () => ({
+ estimateBlockDimensions: () => ({ width: 200, height: 100 }),
+ SUBFLOW_CHILD_NODE_CLASS: 'subflow-child',
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block',
+ () => ({
+ PreviewBlock: () => null,
+ })
+)
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow',
+ () => ({
+ PreviewSubflow: () => null,
+ })
+)
+
+import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow'
+
+const workflowState = { blocks: {}, edges: [] } as WorkflowState
+let host: HTMLDivElement | undefined
+let root: ReturnType | undefined
+let cursorStyles: HTMLStyleElement | undefined
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ host?.remove()
+ cursorStyles?.remove()
+ root = undefined
+ host = undefined
+ cursorStyles = undefined
+ vi.unstubAllGlobals()
+})
+
+describe('PreviewWorkflow cursors', () => {
+ it('keeps two mounted previews independent when either cursor changes', () => {
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ }
+ )
+ host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+
+ act(() =>
+ root?.render(
+ <>
+
+ {}}
+ />
+ >
+ )
+ )
+
+ const previews = host.querySelectorAll('.preview-mode')
+ expect(previews).toHaveLength(2)
+ expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('grab')
+ expect(previews[0]).toHaveAttribute('data-preview-grab')
+ expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('pointer')
+ expect(previews[1]).not.toHaveAttribute('data-preview-grab')
+ expect(previews[1]).toHaveClass('interactive-nodes')
+
+ act(() =>
+ root?.render(
+ <>
+
+ {}}
+ />
+ >
+ )
+ )
+
+ expect(previews[0].style.getPropertyValue('--preview-cursor')).toBe('default')
+ expect(previews[0]).not.toHaveAttribute('data-preview-grab')
+ expect(previews[1].style.getPropertyValue('--preview-cursor')).toBe('grab')
+ expect(previews[1]).toHaveAttribute('data-preview-grab')
+ expect(previews[1]).toHaveClass('interactive-nodes')
+ })
+
+ it('applies the production cursor CSS only within each mounted preview', () => {
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ disconnect() {}
+ }
+ )
+
+ // JSDOM computes stylesheet rules but cannot keep an element in :active after
+ // a pointer event. Map that pseudo-class to an attribute for this CSS test.
+ cursorStyles = document.createElement('style')
+ cursorStyles.textContent = readFileSync(
+ 'app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css',
+ 'utf8'
+ ).replaceAll(':active', '[data-test-active]')
+ document.head.appendChild(cursorStyles)
+
+ host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+
+ const render = (first: 'grab' | 'default', second: 'grab' | 'pointer') =>
+ act(() =>
+ root?.render(
+ <>
+
+ {}}
+ />
+ >
+ )
+ )
+
+ render('grab', 'pointer')
+
+ const previews = host.querySelectorAll('.preview-mode')
+ expect(previews).toHaveLength(2)
+ const panes = Array.from(
+ previews,
+ (preview) => preview.querySelector('.react-flow__pane')!
+ )
+ const nodes = Array.from(
+ previews,
+ (preview) => preview.querySelector('.react-flow__node')!
+ )
+
+ for (const element of [...panes, ...nodes]) element.setAttribute('data-test-active', '')
+
+ expect(getComputedStyle(panes[0]).cursor).toBe('grabbing')
+ expect(getComputedStyle(nodes[0]).cursor).toBe('grabbing')
+ expect(getComputedStyle(panes[1]).cursor).toBe('var(--preview-cursor)')
+ expect(getComputedStyle(nodes[1]).cursor).toBe('pointer')
+
+ render('default', 'grab')
+
+ expect(getComputedStyle(panes[0]).cursor).toBe('var(--preview-cursor)')
+ expect(getComputedStyle(nodes[0]).cursor).not.toBe('grabbing')
+ expect(getComputedStyle(panes[1]).cursor).toBe('grabbing')
+ expect(getComputedStyle(nodes[1]).cursor).toBe('pointer')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css
new file mode 100644
index 00000000000..fa7f7f69ce9
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css
@@ -0,0 +1,27 @@
+.preview-mode .react-flow,
+.preview-mode .react-flow__renderer {
+ cursor: var(--preview-cursor);
+}
+
+.preview-mode .react-flow__pane,
+.preview-mode .react-flow__selectionpane {
+ cursor: var(--preview-cursor) !important;
+}
+
+.preview-mode:where([data-preview-grab]) .react-flow:active,
+.preview-mode:where([data-preview-grab]) .react-flow__renderer:active {
+ cursor: grabbing;
+}
+
+.preview-mode:where([data-preview-grab]) .react-flow__pane:active,
+.preview-mode:where([data-preview-grab]) .react-flow__selectionpane:active,
+.preview-mode:where([data-preview-grab]) .react-flow__node:active,
+.preview-mode:where([data-preview-grab]) .react-flow__node:active * {
+ cursor: grabbing !important;
+}
+
+.preview-mode.interactive-nodes .react-flow__node,
+.preview-mode.interactive-nodes .react-flow__node > div,
+.preview-mode.interactive-nodes .react-flow__node * {
+ cursor: pointer !important;
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
index cdadfef7750..a4c175a64eb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useMemo, useRef } from 'react'
+import { type CSSProperties, useEffect, useMemo, useRef } from 'react'
import {
ConnectionLineType,
type Edge,
@@ -38,6 +38,7 @@ import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
+import '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.css'
const logger = createLogger('PreviewWorkflow')
@@ -627,35 +628,17 @@ export function PreviewWorkflow({
-