From d1e25d338ef0c1d8825f2c3c634b09080d467982 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 12:49:22 -0700 Subject: [PATCH 1/9] feat(workflow): deploy workflows from resource panel --- .../resource-content/resource-content.tsx | 16 +- .../panel/components/deploy/deploy.test.tsx | 214 ++++++++++++++++++ .../panel/components/deploy/deploy.tsx | 15 +- 3 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index f1bfdc3086c..06871accb71 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -52,12 +52,14 @@ import { useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' +import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' -import { useFolders } from '@/hooks/queries/folders' +import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useLogDetail } from '@/hooks/queries/logs' import { downloadTableExport } from '@/hooks/queries/tables' -import { useWorkflows } from '@/hooks/queries/workflows' +import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree' +import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useExecutionStore } from '@/stores/execution/store' @@ -360,6 +362,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) + const { data: folderMap = {} } = useFolderMap(workspaceId) const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( @@ -379,6 +383,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const isRunButtonDisabled = !isExecuting && (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading)) + const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap[workflowId], folderMap) const handleRun = async () => { setActiveWorkflow(workflowId) @@ -450,6 +455,13 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor

{isExecuting ? 'Stop' : 'Run workflow'}

+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx new file mode 100644 index 00000000000..a38ab5fa033 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -0,0 +1,214 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mockState = vi.hoisted(() => ({ + hydrationPhase: 'ready' as 'idle' | 'state-loading' | 'ready', + hasBlocks: true, + isDeployed: false, + changeDetected: false, + isChangeDetectionSettling: false, + isDeploying: false, + readiness: { + isBlocked: false, + isSyncing: false, + tooltip: 'Ready to deploy', + }, + handleDeployClick: vi.fn(), + modalProps: null as { open: boolean } | null, +})) + +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: { children: React.ReactNode }) => <>{children}, + Trigger: ({ children }: { children: React.ReactNode }) => <>{children}, + Content: ({ children }: { children: React.ReactNode }) =>
{children}
, + }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + Rocket: () => , +})) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal', + () => ({ + DeployModal: (props: { open: boolean }) => { + mockState.modalProps = props + return props.open ?
Deploy workflow
: null + }, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks', + () => ({ + useChangeDetection: () => ({ + changeDetected: mockState.changeDetected, + isChangeDetectionSettling: mockState.isChangeDetectionSettling, + }), + useDeployment: () => ({ + isDeploying: mockState.isDeploying, + handleDeployClick: mockState.handleDeployClick, + }), + useDeployReadiness: () => ({ + ...mockState.readiness, + status: mockState.readiness.isBlocked ? 'saving' : 'ready', + isReady: !mockState.readiness.isBlocked, + waitUntilReady: vi.fn(), + }), + }) +) + +vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow', () => ({ + useCurrentWorkflow: () => ({ hasBlocks: () => mockState.hasBlocks }), +})) + +vi.mock('@/hooks/queries/deployments', () => ({ + useDeploymentInfo: () => ({ data: { isDeployed: mockState.isDeployed } }), + useDeployedWorkflowState: () => ({ data: null, isLoading: false, isFetching: false }), +})) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: (selector: (state: unknown) => unknown) => + selector({ hydration: { phase: mockState.hydrationPhase } }), +})) + +import { Deploy } from './deploy' + +let container: HTMLDivElement +let root: Root + +function renderDeploy(overrides: Partial = {}) { + Object.assign(mockState, overrides) + act(() => { + root.render( + + ) + }) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockState.hydrationPhase = 'ready' + mockState.hasBlocks = true + mockState.isDeployed = false + mockState.changeDetected = false + mockState.isChangeDetectionSettling = false + mockState.isDeploying = false + mockState.readiness = { + isBlocked: false, + isSyncing: false, + tooltip: 'Ready to deploy', + } + mockState.handleDeployClick.mockReset() + mockState.handleDeployClick.mockResolvedValue({ success: true, shouldOpenModal: true }) + mockState.modalProps = null +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('Deploy compact mode', () => { + it('renders an accessible compact Deploy action for an undeployed workflow', () => { + renderDeploy() + + const button = container.querySelector('button') + expect(button?.getAttribute('aria-label')).toBe('Deploy') + expect(button?.className).toContain('resource-action') + expect(container.querySelector('[data-testid="rocket-icon"]')).not.toBeNull() + expect(container.textContent).not.toContain('DeployLiveUpdate') + }) + + it('uses the deployment status in the compact action label', () => { + renderDeploy({ isDeployed: true }) + expect(container.querySelector('button')?.getAttribute('aria-label')).toBe('Live') + + renderDeploy({ isDeployed: true, changeDetected: true }) + expect(container.querySelector('button')?.getAttribute('aria-label')).toBe('Update') + }) + + it.each([ + ['non-admin users', { canAdmin: false }], + ['empty workflows', { hasBlocks: false }], + ['locked workflows', { disabled: true }], + [ + 'unsynchronized workflows', + { + readiness: { isBlocked: true, isSyncing: false, tooltip: 'Saving workflow changes' }, + }, + ], + ])('disables the action for %s', (_reason, overrides) => { + const permissions = + overrides.canAdmin === false + ? { + canRead: true, + canEdit: true, + canAdmin: false, + userPermissions: 'write' as const, + isLoading: false, + error: null, + } + : undefined + + Object.assign(mockState, overrides) + act(() => { + root.render( + + ) + }) + + expect(container.querySelector('button')?.disabled).toBe(true) + }) + + it('opens the existing deployment modal after a successful deployment action', async () => { + renderDeploy() + + await act(async () => { + container.querySelector('button')?.click() + }) + + expect(mockState.handleDeployClick).toHaveBeenCalledOnce() + expect(mockState.modalProps?.open).toBe(true) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 598207320da..acac9b28fb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,8 @@ 'use client' import { useState } from 'react' -import { Button, Tooltip } from '@sim/emcn' +import { Button, cn, Tooltip } from '@sim/emcn' +import { Rocket } from '@sim/emcn/icons' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -17,6 +18,7 @@ interface DeployProps { activeWorkflowId: string | null userPermissions: WorkspaceUserPermissions className?: string + compact?: boolean disabled?: boolean } @@ -24,6 +26,7 @@ export function Deploy({ activeWorkflowId, userPermissions, className, + compact = false, disabled = false, }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) @@ -119,24 +122,28 @@ export function Deploy({ return 'Deploy' } + const buttonLabel = getButtonLabel() + const tooltipText = getTooltipText() + return ( <> - {getTooltipText()} + {tooltipText} Date: Thu, 6 Aug 2026 13:00:07 -0700 Subject: [PATCH 2/9] fix(workflow): match deploy resource action styling --- .../panel/components/deploy/deploy.test.tsx | 5 +++-- .../components/panel/components/deploy/deploy.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx index a38ab5fa033..d04e03ad632 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -36,7 +36,7 @@ vi.mock('@sim/emcn', () => ({ })) vi.mock('@sim/emcn/icons', () => ({ - Rocket: () => , + Upload: () => , })) vi.mock( @@ -141,7 +141,8 @@ describe('Deploy compact mode', () => { const button = container.querySelector('button') expect(button?.getAttribute('aria-label')).toBe('Deploy') expect(button?.className).toContain('resource-action') - expect(container.querySelector('[data-testid="rocket-icon"]')).not.toBeNull() + expect(container.querySelector('[data-testid="upload-icon"]')).not.toBeNull() + expect(button?.getAttribute('variant')).toBe('subtle') expect(container.textContent).not.toContain('DeployLiveUpdate') }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index acac9b28fb5..1cd3490b6b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, cn, Tooltip } from '@sim/emcn' -import { Rocket } from '@sim/emcn/icons' +import { Upload } from '@sim/emcn/icons' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -133,13 +133,19 @@ export function Deploy({ From b923c2614b9f4bf9a14baff95d53af5b79ecfe3b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 15:31:27 -0700 Subject: [PATCH 3/9] fix(workflow): guard resource deploy metadata loading --- .../components/resource-content/resource-content.tsx | 10 +++++++--- .../components/panel/components/deploy/deploy.test.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 06871accb71..bddcd28eb3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -362,8 +362,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() - const { data: workflowMap = {} } = useWorkflowMap(workspaceId) - const { data: folderMap = {} } = useFolderMap(workspaceId) + const { data: workflowMap, isLoading: isWorkflowMapLoading } = useWorkflowMap(workspaceId) + const { data: folderMap, isLoading: isFolderMapLoading } = useFolderMap(workspaceId) const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( @@ -383,7 +383,11 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const isRunButtonDisabled = !isExecuting && (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading)) - const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap[workflowId], folderMap) + const isWorkflowLockDataLoading = + isWorkflowMapLoading || isFolderMapLoading || !workflowMap || !folderMap + const isWorkflowLocked = + isWorkflowLockDataLoading || + isWorkflowEffectivelyLocked(workflowMap?.[workflowId], folderMap ?? {}) const handleRun = async () => { setActiveWorkflow(workflowId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx index d04e03ad632..8bb01474591 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -83,7 +83,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({ selector({ hydration: { phase: mockState.hydrationPhase } }), })) -import { Deploy } from './deploy' +import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy' let container: HTMLDivElement let root: Root From d06155812c6a8d7f03e69415381f80e776097a36 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 15:33:30 -0700 Subject: [PATCH 4/9] fix(workflow): wait for active workflow hydration before deploy --- .../panel/components/deploy/deploy.test.tsx | 18 +++++++++++++++++- .../panel/components/deploy/deploy.tsx | 8 ++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx index 8bb01474591..ef21783afad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockState = vi.hoisted(() => ({ hydrationPhase: 'ready' as 'idle' | 'state-loading' | 'ready', + hydrationWorkflowId: 'workflow-1' as string | null, + registryActiveWorkflowId: 'workflow-1' as string | null, hasBlocks: true, isDeployed: false, changeDetected: false, @@ -80,7 +82,13 @@ vi.mock('@/hooks/queries/deployments', () => ({ vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: (selector: (state: unknown) => unknown) => - selector({ hydration: { phase: mockState.hydrationPhase } }), + selector({ + activeWorkflowId: mockState.registryActiveWorkflowId, + hydration: { + phase: mockState.hydrationPhase, + workflowId: mockState.hydrationWorkflowId, + }, + }), })) import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy' @@ -114,6 +122,8 @@ beforeEach(() => { document.body.appendChild(container) root = createRoot(container) mockState.hydrationPhase = 'ready' + mockState.hydrationWorkflowId = 'workflow-1' + mockState.registryActiveWorkflowId = 'workflow-1' mockState.hasBlocks = true mockState.isDeployed = false mockState.changeDetected = false @@ -154,6 +164,12 @@ describe('Deploy compact mode', () => { expect(container.querySelector('button')?.getAttribute('aria-label')).toBe('Update') }) + it('disables deployment while the active tab is still hydrating another workflow', () => { + renderDeploy({ hydrationWorkflowId: 'workflow-2', registryActiveWorkflowId: 'workflow-2' }) + + expect(container.querySelector('button')?.disabled).toBe(true) + }) + it.each([ ['non-admin users', { canAdmin: false }], ['empty workflows', { hasBlocks: false }], diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 1cd3490b6b7..4678886b690 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -30,8 +30,12 @@ export function Deploy({ disabled = false, }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) - const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' + const registryActiveWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const hydration = useWorkflowRegistry((state) => state.hydration) + const isRegistryLoading = + hydration.phase !== 'ready' || + registryActiveWorkflowId !== activeWorkflowId || + hydration.workflowId !== activeWorkflowId const { hasBlocks } = useCurrentWorkflow() const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, { From 78171b730d5cc8f6c2bd8274080af9eda67f835e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 15:42:32 -0700 Subject: [PATCH 5/9] fix(workflow): clarify resource deploy loading states --- .../resource-content/resource-content.tsx | 10 ++++------ .../panel/components/deploy/deploy.test.tsx | 14 +++++++++++++- .../components/panel/components/deploy/deploy.tsx | 7 ++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index bddcd28eb3d..7b03e3b32b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -383,11 +383,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const isRunButtonDisabled = !isExecuting && (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading)) - const isWorkflowLockDataLoading = - isWorkflowMapLoading || isFolderMapLoading || !workflowMap || !folderMap - const isWorkflowLocked = - isWorkflowLockDataLoading || - isWorkflowEffectivelyLocked(workflowMap?.[workflowId], folderMap ?? {}) + const isWorkflowLockDataLoading = isWorkflowMapLoading || isFolderMapLoading + const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap?.[workflowId], folderMap ?? {}) const handleRun = async () => { setActiveWorkflow(workflowId) @@ -464,7 +461,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor userPermissions={effectivePermissions} className={RESOURCE_TAB_ICON_BUTTON_CLASS} compact - disabled={isWorkflowLocked} + disabled={isWorkflowLockDataLoading || isWorkflowLocked} + disabledTooltip={isWorkflowLockDataLoading ? 'Loading workflow lock status...' : undefined} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx index ef21783afad..2d7b4834b1a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -96,7 +96,10 @@ import { Deploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/ let container: HTMLDivElement let root: Root -function renderDeploy(overrides: Partial = {}) { +function renderDeploy( + overrides: Partial = {}, + props: { disabled?: boolean; disabledTooltip?: string } = {} +) { Object.assign(mockState, overrides) act(() => { root.render( @@ -112,6 +115,7 @@ function renderDeploy(overrides: Partial = {}) { }} compact className='resource-action' + {...props} /> ) }) @@ -168,6 +172,14 @@ describe('Deploy compact mode', () => { renderDeploy({ hydrationWorkflowId: 'workflow-2', registryActiveWorkflowId: 'workflow-2' }) expect(container.querySelector('button')?.disabled).toBe(true) + expect(container.textContent).toContain('Loading workflow...') + }) + + it('uses a caller-provided tooltip for external loading states', () => { + renderDeploy({}, { disabled: true, disabledTooltip: 'Loading workflow lock status...' }) + + expect(container.querySelector('button')?.disabled).toBe(true) + expect(container.textContent).toContain('Loading workflow lock status...') }) it.each([ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 4678886b690..de5db536c8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -20,6 +20,7 @@ interface DeployProps { className?: string compact?: boolean disabled?: boolean + disabledTooltip?: string } export function Deploy({ @@ -28,6 +29,7 @@ export function Deploy({ className, compact = false, disabled = false, + disabledTooltip, }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const registryActiveWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -89,6 +91,9 @@ export function Deploy({ } const getTooltipText = () => { + if (isRegistryLoading) { + return 'Loading workflow...' + } if (isEmpty) { return 'Cannot deploy an empty workflow' } @@ -96,7 +101,7 @@ export function Deploy({ return 'Admin permissions required' } if (disabled) { - return 'Workflow is locked' + return disabledTooltip ?? 'Workflow is locked' } if (isDeploying) { return 'Deploying...' From d3c3137ef63fec423eb3e672d20ff9479c30b9b4 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 15:47:42 -0700 Subject: [PATCH 6/9] fix(workflow): retry unavailable resource lock data --- .../resource-content/resource-content.tsx | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 7b03e3b32b1..6ec977346b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -7,6 +7,7 @@ import { FileX, Folder as FolderIcon, Library, + RefreshCw, Square, SquareArrowUpRight, Workflow as WorkflowIcon, @@ -362,8 +363,16 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() - const { data: workflowMap, isLoading: isWorkflowMapLoading } = useWorkflowMap(workspaceId) - const { data: folderMap, isLoading: isFolderMapLoading } = useFolderMap(workspaceId) + const { + data: workflowMap, + isLoading: isWorkflowMapLoading, + refetch: refetchWorkflowMap, + } = useWorkflowMap(workspaceId) + const { + data: folderMap, + isLoading: isFolderMapLoading, + refetch: refetchFolderMap, + } = useFolderMap(workspaceId) const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( @@ -384,8 +393,14 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor !isExecuting && (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading)) const isWorkflowLockDataLoading = isWorkflowMapLoading || isFolderMapLoading + const isWorkflowLockDataUnavailable = !workflowMap || !folderMap const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap?.[workflowId], folderMap ?? {}) + const handleRetryWorkflowLockData = () => { + if (!workflowMap) void refetchWorkflowMap() + if (!folderMap) void refetchFolderMap() + } + const handleRun = async () => { setActiveWorkflow(workflowId) @@ -456,14 +471,34 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor

{isExecuting ? 'Stop' : 'Run workflow'}

- + {isWorkflowLockDataUnavailable && !isWorkflowLockDataLoading ? ( + + + + + +

Retry loading workflow lock status

+
+
+ ) : ( + + )} ) } From c6c750878e991708ca4a93d64d92b5362bb8bef5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 15:56:57 -0700 Subject: [PATCH 7/9] test(workflow): focus deploy coverage on behavior --- .../panel/components/deploy/deploy.test.tsx | 105 +++++------------- 1 file changed, 25 insertions(+), 80 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx index 2d7b4834b1a..09b21a5c0db 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.test.tsx @@ -20,7 +20,6 @@ const mockState = vi.hoisted(() => ({ tooltip: 'Ready to deploy', }, handleDeployClick: vi.fn(), - modalProps: null as { open: boolean } | null, })) vi.mock('@sim/emcn', () => ({ @@ -38,16 +37,14 @@ vi.mock('@sim/emcn', () => ({ })) vi.mock('@sim/emcn/icons', () => ({ - Upload: () => , + Upload: () => , })) vi.mock( '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal', () => ({ - DeployModal: (props: { open: boolean }) => { - mockState.modalProps = props - return props.open ?
Deploy workflow
: null - }, + DeployModal: ({ open }: { open: boolean }) => + open ?
Deploy workflow
: null, }) ) @@ -98,7 +95,7 @@ let root: Root function renderDeploy( overrides: Partial = {}, - props: { disabled?: boolean; disabledTooltip?: string } = {} + props: { disabled?: boolean; canAdmin?: boolean } = {} ) { Object.assign(mockState, overrides) act(() => { @@ -108,14 +105,13 @@ function renderDeploy( userPermissions={{ canRead: true, canEdit: true, - canAdmin: true, - userPermissions: 'admin', + canAdmin: props.canAdmin ?? true, + userPermissions: props.canAdmin === false ? 'write' : 'admin', isLoading: false, error: null, }} compact - className='resource-action' - {...props} + disabled={props.disabled} /> ) }) @@ -140,7 +136,6 @@ beforeEach(() => { } mockState.handleDeployClick.mockReset() mockState.handleDeployClick.mockResolvedValue({ success: true, shouldOpenModal: true }) - mockState.modalProps = null }) afterEach(() => { @@ -149,82 +144,34 @@ afterEach(() => { }) describe('Deploy compact mode', () => { - it('renders an accessible compact Deploy action for an undeployed workflow', () => { - renderDeploy() - - const button = container.querySelector('button') - expect(button?.getAttribute('aria-label')).toBe('Deploy') - expect(button?.className).toContain('resource-action') - expect(container.querySelector('[data-testid="upload-icon"]')).not.toBeNull() - expect(button?.getAttribute('variant')).toBe('subtle') - expect(container.textContent).not.toContain('DeployLiveUpdate') - }) - - it('uses the deployment status in the compact action label', () => { - renderDeploy({ isDeployed: true }) - expect(container.querySelector('button')?.getAttribute('aria-label')).toBe('Live') - - renderDeploy({ isDeployed: true, changeDetected: true }) - expect(container.querySelector('button')?.getAttribute('aria-label')).toBe('Update') - }) - - it('disables deployment while the active tab is still hydrating another workflow', () => { - renderDeploy({ hydrationWorkflowId: 'workflow-2', registryActiveWorkflowId: 'workflow-2' }) - - expect(container.querySelector('button')?.disabled).toBe(true) - expect(container.textContent).toContain('Loading workflow...') - }) - - it('uses a caller-provided tooltip for external loading states', () => { - renderDeploy({}, { disabled: true, disabledTooltip: 'Loading workflow lock status...' }) + it.each([ + ['Deploy', {}], + ['Live', { isDeployed: true }], + ['Update', { isDeployed: true, changeDetected: true }], + ])('exposes the %s action for its deployment state', (label, overrides) => { + renderDeploy(overrides) - expect(container.querySelector('button')?.disabled).toBe(true) - expect(container.textContent).toContain('Loading workflow lock status...') + expect(container.querySelector('button')?.getAttribute('aria-label')).toBe(label) }) it.each([ - ['non-admin users', { canAdmin: false }], - ['empty workflows', { hasBlocks: false }], - ['locked workflows', { disabled: true }], + ['non-admin users', {}, { canAdmin: false }], + ['empty workflows', { hasBlocks: false }, {}], + ['locked workflows', {}, { disabled: true }], [ 'unsynchronized workflows', { readiness: { isBlocked: true, isSyncing: false, tooltip: 'Saving workflow changes' }, }, + {}, ], - ])('disables the action for %s', (_reason, overrides) => { - const permissions = - overrides.canAdmin === false - ? { - canRead: true, - canEdit: true, - canAdmin: false, - userPermissions: 'write' as const, - isLoading: false, - error: null, - } - : undefined - - Object.assign(mockState, overrides) - act(() => { - root.render( - - ) - }) + [ + 'workflows that are still loading', + { hydrationWorkflowId: 'workflow-2', registryActiveWorkflowId: 'workflow-2' }, + {}, + ], + ])('disables the action for %s', (_reason, overrides, props) => { + renderDeploy(overrides, props) expect(container.querySelector('button')?.disabled).toBe(true) }) @@ -236,8 +183,6 @@ describe('Deploy compact mode', () => { container.querySelector('button')?.click() }) - expect(mockState.handleDeployClick).toHaveBeenCalledOnce() - expect(mockState.modalProps?.open).toBe(true) expect(container.querySelector('[role="dialog"]')).not.toBeNull() }) }) From 22314f20a04ca4e0b583bfe621e4d3543541c3be Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 16:03:13 -0700 Subject: [PATCH 8/9] refactor(workflow): align resource deploy with editor --- .../resource-content/resource-content.tsx | 57 ++++--------------- .../panel/components/deploy/deploy.tsx | 4 +- 2 files changed, 11 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 6ec977346b2..06871accb71 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -7,7 +7,6 @@ import { FileX, Folder as FolderIcon, Library, - RefreshCw, Square, SquareArrowUpRight, Workflow as WorkflowIcon, @@ -363,16 +362,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() - const { - data: workflowMap, - isLoading: isWorkflowMapLoading, - refetch: refetchWorkflowMap, - } = useWorkflowMap(workspaceId) - const { - data: folderMap, - isLoading: isFolderMapLoading, - refetch: refetchFolderMap, - } = useFolderMap(workspaceId) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) + const { data: folderMap = {} } = useFolderMap(workspaceId) const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( @@ -392,14 +383,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const isRunButtonDisabled = !isExecuting && (isUsageGateLoading || (!effectivePermissions.canRead && !effectivePermissions.isLoading)) - const isWorkflowLockDataLoading = isWorkflowMapLoading || isFolderMapLoading - const isWorkflowLockDataUnavailable = !workflowMap || !folderMap - const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap?.[workflowId], folderMap ?? {}) - - const handleRetryWorkflowLockData = () => { - if (!workflowMap) void refetchWorkflowMap() - if (!folderMap) void refetchFolderMap() - } + const isWorkflowLocked = isWorkflowEffectivelyLocked(workflowMap[workflowId], folderMap) const handleRun = async () => { setActiveWorkflow(workflowId) @@ -471,34 +455,13 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor

{isExecuting ? 'Stop' : 'Run workflow'}

- {isWorkflowLockDataUnavailable && !isWorkflowLockDataLoading ? ( - - - - - -

Retry loading workflow lock status

-
-
- ) : ( - - )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index de5db536c8b..859d8cd33f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -20,7 +20,6 @@ interface DeployProps { className?: string compact?: boolean disabled?: boolean - disabledTooltip?: string } export function Deploy({ @@ -29,7 +28,6 @@ export function Deploy({ className, compact = false, disabled = false, - disabledTooltip, }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const registryActiveWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -101,7 +99,7 @@ export function Deploy({ return 'Admin permissions required' } if (disabled) { - return disabledTooltip ?? 'Workflow is locked' + return 'Workflow is locked' } if (isDeploying) { return 'Deploying...' From 8155ebdc4db69eac07e999754ba29418b6178227 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 6 Aug 2026 16:51:47 -0700 Subject: [PATCH 9/9] fix(workflow): wait for resource lock metadata --- .../components/resource-content/resource-content.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 06871accb71..b91a47eeeea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -362,8 +362,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { userPermissions: effectivePermissions } = useWorkspacePermissionsContext() - const { data: workflowMap = {} } = useWorkflowMap(workspaceId) - const { data: folderMap = {} } = useFolderMap(workspaceId) + const { data: workflowMap = {}, isLoading: isWorkflowMapLoading } = useWorkflowMap(workspaceId) + const { data: folderMap = {}, isLoading: isFolderMapLoading } = useFolderMap(workspaceId) const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const isExecuting = useExecutionStore( @@ -460,7 +460,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor userPermissions={effectivePermissions} className={RESOURCE_TAB_ICON_BUTTON_CLASS} compact - disabled={isWorkflowLocked} + disabled={isWorkflowMapLoading || isFolderMapLoading || isWorkflowLocked} /> )