From 65fc138bfc3041a0f8d4b4614ce0484f7866174c Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 17:44:10 -0700 Subject: [PATCH 01/10] improvement(stores): remove deployment state from Zustand in favor of React Query (#3923) --- .../copilot/checkpoints/revert/route.test.ts | 13 --- .../api/copilot/checkpoints/revert/route.ts | 1 - .../deployments/[version]/revert/route.ts | 1 - apps/sim/app/api/workflows/[id]/route.ts | 2 - .../templates/components/template-card.tsx | 2 - .../[workspaceId]/home/hooks/use-chat.ts | 11 -- .../templates/components/template-card.tsx | 2 - .../components/deploy-modal/deploy-modal.tsx | 8 +- .../panel/components/deploy/deploy.tsx | 10 +- .../hooks/use-current-workflow.ts | 7 -- .../w/[workflowId]/utils/auto-layout-utils.ts | 3 +- .../workspace/providers/socket-provider.tsx | 1 - apps/sim/hooks/queries/deployments.ts | 60 +++------- .../queries/utils/custom-tool-cache.test.ts | 39 ------- .../hooks/queries/utils/custom-tool-cache.ts | 23 ---- apps/sim/hooks/use-collaborative-workflow.ts | 1 - .../tools/client/tool-display-registry.ts | 15 ++- apps/sim/lib/logs/types.ts | 3 +- apps/sim/lib/workflows/defaults.ts | 2 - .../lib/workflows/persistence/utils.test.ts | 1 - apps/sim/socket/database/operations.ts | 1 - apps/sim/stores/index.ts | 1 - apps/sim/stores/workflows/index.ts | 105 +---------------- apps/sim/stores/workflows/registry/store.ts | 110 +++--------------- apps/sim/stores/workflows/registry/types.ts | 16 --- apps/sim/stores/workflows/workflow/store.ts | 74 ------------ apps/sim/stores/workflows/workflow/types.ts | 5 - apps/sim/tools/index.test.ts | 17 ++- 28 files changed, 64 insertions(+), 470 deletions(-) delete mode 100644 apps/sim/hooks/queries/utils/custom-tool-cache.test.ts delete mode 100644 apps/sim/hooks/queries/utils/custom-tool-cache.ts diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index fe3246181d4..7fd68b4925e 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => { loops: {}, parallels: {}, isDeployed: true, - deploymentStatuses: { production: 'deployed' }, }, } @@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => { loops: {}, parallels: {}, isDeployed: true, - deploymentStatuses: { production: 'deployed' }, lastSaved: 1640995200000, }, }, @@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => { loops: {}, parallels: {}, isDeployed: true, - deploymentStatuses: { production: 'deployed' }, lastSaved: 1640995200000, }), } @@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => { edges: undefined, loops: null, parallels: undefined, - deploymentStatuses: null, }, } @@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => { loops: {}, parallels: {}, isDeployed: false, - deploymentStatuses: {}, lastSaved: 1640995200000, }) }) @@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => { parallel1: { branches: ['branch1', 'branch2'] }, }, isDeployed: true, - deploymentStatuses: { - production: 'deployed', - staging: 'pending', - }, deployedAt: '2024-01-01T10:00:00.000Z', }, } @@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => { parallel1: { branches: ['branch1', 'branch2'] }, }, isDeployed: true, - deploymentStatuses: { - production: 'deployed', - staging: 'pending', - }, deployedAt: '2024-01-01T10:00:00.000Z', lastSaved: 1640995200000, }) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 2edf7d2dec7..dd73477f5ec 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -82,7 +82,6 @@ export async function POST(request: NextRequest) { loops: checkpointState?.loops || {}, parallels: checkpointState?.parallels || {}, isDeployed: checkpointState?.isDeployed || false, - deploymentStatuses: checkpointState?.deploymentStatuses || {}, lastSaved: Date.now(), ...(checkpointState?.deployedAt && checkpointState.deployedAt !== null && diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 618a1de8f94..a209db29eb4 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -79,7 +79,6 @@ export async function POST( loops: deployedState.loops || {}, parallels: deployedState.parallels || {}, lastSaved: Date.now(), - deploymentStatuses: deployedState.deploymentStatuses || {}, }) if (!saveResult.success) { diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 95a6ee43dc7..3d74fe527fa 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -89,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const finalWorkflowData = { ...workflowData, state: { - deploymentStatuses: {}, blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, @@ -115,7 +114,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const emptyWorkflowData = { ...workflowData, state: { - deploymentStatuses: {}, blocks: {}, edges: [], loops: {}, diff --git a/apps/sim/app/templates/components/template-card.tsx b/apps/sim/app/templates/components/template-card.tsx index 15fb0cec4cc..7a2f3d2e105 100644 --- a/apps/sim/app/templates/components/template-card.tsx +++ b/apps/sim/app/templates/components/template-card.tsx @@ -108,8 +108,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null { lastUpdate: input.lastUpdate, metadata: input.metadata, variables: input.variables, - deploymentStatuses: input.deploymentStatuses, - needsRedeployment: input.needsRedeployment, dragStartPosition: input.dragStartPosition ?? null, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5fa965d9abe..88866a77000 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1407,17 +1407,6 @@ export function useChat( const output = tc.result?.output as Record | undefined const deployedWorkflowId = (output?.workflowId as string) ?? undefined if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') { - const isDeployed = output.isDeployed as boolean - const serverDeployedAt = output.deployedAt - ? new Date(output.deployedAt as string) - : undefined - useWorkflowRegistry - .getState() - .setDeploymentStatus( - deployedWorkflowId, - isDeployed, - isDeployed ? (serverDeployedAt ?? new Date()) : undefined - ) queryClient.invalidateQueries({ queryKey: deploymentKeys.info(deployedWorkflowId), }) 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 8671a709271..a93cb915c69 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx @@ -111,8 +111,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null { lastUpdate: input.lastUpdate, metadata: input.metadata, variables: input.variables, - deploymentStatuses: input.deploymentStatuses, - needsRedeployment: input.needsRedeployment, dragStartPosition: input.dragStartPosition ?? null, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index f6ce4e95c2b..4133728b666 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -40,7 +40,6 @@ import { useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -90,10 +89,7 @@ export function DeployModal({ const params = useParams() const workspaceId = params?.workspaceId as string const { navigateToSettings } = useSettingsNavigation() - const deploymentStatus = useWorkflowRegistry((state) => - state.getWorkflowDeploymentStatus(workflowId) - ) - const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp + const isDeployed = isDeployedProp const { data: workflowMap = {} } = useWorkflowMap(workspaceId) const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null @@ -381,8 +377,6 @@ export function DeployModal({ invalidateDeploymentQueries(queryClient, workflowId) - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - if (chatSuccessTimeoutRef.current) { clearTimeout(chatSuccessTimeoutRef.current) } 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 78081e6eeac..24a0975325c 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 @@ -9,7 +9,7 @@ import { useDeployment, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' -import { useDeployedWorkflowState } from '@/hooks/queries/deployments' +import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -25,10 +25,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() - const deploymentStatus = useWorkflowRegistry((state) => - state.getWorkflowDeploymentStatus(activeWorkflowId) - ) - const isDeployed = deploymentStatus?.isDeployed || false + const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, { + enabled: !isRegistryLoading, + }) + const isDeployed = deploymentInfo?.isDeployed ?? false const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading const { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts index a74573b07b0..85143170e5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import type { Edge } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import type { DeploymentStatus } from '@/stores/workflows/registry/types' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' @@ -16,8 +15,6 @@ export interface CurrentWorkflow { loops: Record parallels: Record lastSaved?: number - deploymentStatuses?: Record - needsRedeployment?: boolean // Mode information isDiffMode: boolean @@ -48,8 +45,6 @@ export function useCurrentWorkflow(): CurrentWorkflow { loops: state.loops, parallels: state.parallels, lastSaved: state.lastSaved, - deploymentStatuses: state.deploymentStatuses, - needsRedeployment: state.needsRedeployment, })) ) @@ -78,8 +73,6 @@ export function useCurrentWorkflow(): CurrentWorkflow { loops: activeWorkflow.loops || {}, parallels: activeWorkflow.parallels || {}, lastSaved: activeWorkflow.lastSaved, - deploymentStatuses: activeWorkflow.deploymentStatuses, - needsRedeployment: activeWorkflow.needsRedeployment, // Mode information - update to reflect ready state isDiffMode: hasActiveDiff && isShowingDiff, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts index 3b4d5a73ee5..69a15ec7d67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils.ts @@ -124,8 +124,7 @@ export async function applyAutoLayoutAndUpdateStore( try { useWorkflowStore.getState().updateLastSaved() - const { deploymentStatuses, needsRedeployment, dragStartPosition, ...stateToSave } = - newWorkflowState + const { dragStartPosition, ...stateToSave } = newWorkflowState const cleanedWorkflowState = { ...stateToSave, diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 6a27bd3a664..47c00157d0f 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -424,7 +424,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { loops: workflowState.loops || {}, parallels: workflowState.parallels || {}, lastSaved: workflowState.lastSaved || Date.now(), - deploymentStatuses: workflowState.deploymentStatuses || {}, }) useSubBlockStore.setState((state: any) => ({ diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 62f5e970d3b..7bcf8be904d 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -5,7 +5,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeploymentQueries') @@ -321,7 +320,6 @@ interface DeployWorkflowResult { */ export function useDeployWorkflow() { const queryClient = useQueryClient() - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) return useMutation({ mutationFn: async ({ @@ -351,18 +349,12 @@ export function useDeployWorkflow() { warnings: data.warnings, } }, - onSuccess: (data, variables) => { - logger.info('Workflow deployed successfully', { workflowId: variables.workflowId }) - - setDeploymentStatus( - variables.workflowId, - data.isDeployed, - data.deployedAt ? new Date(data.deployedAt) : undefined, - data.apiKey - ) - - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false) - + onSettled: (_data, error, variables) => { + if (error) { + logger.error('Failed to deploy workflow', { error }) + } else { + logger.info('Workflow deployed successfully', { workflowId: variables.workflowId }) + } return Promise.all([ invalidateDeploymentQueries(queryClient, variables.workflowId), queryClient.invalidateQueries({ @@ -370,9 +362,6 @@ export function useDeployWorkflow() { }), ]) }, - onError: (error) => { - logger.error('Failed to deploy workflow', { error }) - }, }) } @@ -389,7 +378,6 @@ interface UndeployWorkflowVariables { */ export function useUndeployWorkflow() { const queryClient = useQueryClient() - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) return useMutation({ mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise => { @@ -402,11 +390,12 @@ export function useUndeployWorkflow() { throw new Error(errorData.error || 'Failed to undeploy workflow') } }, - onSuccess: (_, variables) => { - logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId }) - - setDeploymentStatus(variables.workflowId, false) - + onSettled: (_data, error, variables) => { + if (error) { + logger.error('Failed to undeploy workflow', { error }) + } else { + logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId }) + } return Promise.all([ invalidateDeploymentQueries(queryClient, variables.workflowId), queryClient.invalidateQueries({ @@ -414,9 +403,6 @@ export function useUndeployWorkflow() { }), ]) }, - onError: (error) => { - logger.error('Failed to undeploy workflow', { error }) - }, }) } @@ -613,7 +599,6 @@ interface ActivateVersionResult { */ export function useActivateDeploymentVersion() { const queryClient = useQueryClient() - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) return useMutation({ mutationFn: async ({ @@ -663,20 +648,13 @@ export function useActivateDeploymentVersion() { ) } }, - onSuccess: (data, variables) => { - logger.info('Deployment version activated', { - workflowId: variables.workflowId, - version: variables.version, - }) - - setDeploymentStatus( - variables.workflowId, - true, - data.deployedAt ? new Date(data.deployedAt) : undefined, - data.apiKey - ) - }, - onSettled: (_, __, variables) => { + onSettled: (_data, error, variables) => { + if (!error) { + logger.info('Deployment version activated', { + workflowId: variables.workflowId, + version: variables.version, + }) + } return invalidateDeploymentQueries(queryClient, variables.workflowId) }, }) diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts deleted file mode 100644 index c4b7fd54021..00000000000 --- a/apps/sim/hooks/queries/utils/custom-tool-cache.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { getQueryDataMock } = vi.hoisted(() => ({ - getQueryDataMock: vi.fn(), -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: vi.fn(() => ({ - getQueryData: getQueryDataMock, - })), -})) - -import { getCustomTool, getCustomTools } from '@/hooks/queries/utils/custom-tool-cache' - -describe('custom tool cache helpers', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('reads workspace-scoped custom tools from the cache', () => { - const tools = [{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }] - getQueryDataMock.mockReturnValue(tools) - - expect(getCustomTools('ws-1')).toBe(tools) - expect(getQueryDataMock).toHaveBeenCalledWith(['customTools', 'list', 'ws-1']) - }) - - it('resolves custom tools by id or title', () => { - getQueryDataMock.mockReturnValue([ - { id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }, - ]) - - expect(getCustomTool('tool-1', 'ws-1')?.title).toBe('Weather') - expect(getCustomTool('Weather', 'ws-1')?.id).toBe('tool-1') - }) -}) diff --git a/apps/sim/hooks/queries/utils/custom-tool-cache.ts b/apps/sim/hooks/queries/utils/custom-tool-cache.ts deleted file mode 100644 index 407911ddb37..00000000000 --- a/apps/sim/hooks/queries/utils/custom-tool-cache.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' -import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' - -/** - * Reads custom tools for a workspace directly from the React Query cache. - */ -export function getCustomTools(workspaceId: string): CustomToolDefinition[] { - return ( - getQueryClient().getQueryData(customToolsKeys.list(workspaceId)) ?? [] - ) -} - -/** - * Resolves a custom tool from the cache by id or title. - */ -export function getCustomTool( - identifier: string, - workspaceId: string -): CustomToolDefinition | undefined { - const tools = getCustomTools(workspaceId) - return tools.find((tool) => tool.id === identifier || tool.title === identifier) -} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 4093bed8b20..0b22a47de4b 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -562,7 +562,6 @@ export function useCollaborativeWorkflow() { loops: workflowData.state.loops || {}, parallels: workflowData.state.parallels || {}, lastSaved: workflowData.state.lastSaved || Date.now(), - deploymentStatuses: workflowData.state.deploymentStatuses || {}, }) // Update subblock store with reverted values diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 9a9cb88ca8a..cad83d1ec4c 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -40,7 +40,11 @@ import { XCircle, Zap, } from 'lucide-react' -import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' +import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments' +import { deploymentKeys } from '@/hooks/queries/deployments' +import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -445,7 +449,8 @@ const META_deploy_api: ToolMetadata = { const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId const isAlreadyDeployed = workflowId - ? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed + ? (getQueryClient().getQueryData(deploymentKeys.info(workflowId)) + ?.isDeployed ?? false) : false let actionText = action @@ -1053,7 +1058,11 @@ const META_manage_custom_tool: ToolMetadata = { let toolName = params?.schema?.function?.name if (!toolName && params?.toolId && workspaceId) { try { - const tool = getCustomTool(params.toolId, workspaceId) + const tools = + getQueryClient().getQueryData( + customToolsKeys.list(workspaceId) + ) ?? [] + const tool = tools.find((t) => t.id === params.toolId || t.title === params.toolId) toolName = tool?.schema?.function?.name } catch { // Ignore errors accessing cache diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index b9402583704..20f568ab41c 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -2,10 +2,9 @@ import type { Edge } from 'reactflow' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types' import type { BlockLog, NormalizedBlockOutput } from '@/executor/types' -import type { DeploymentStatus } from '@/stores/workflows/registry/types' import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' -export type { WorkflowState, Loop, Parallel, DeploymentStatus } +export type { WorkflowState, Loop, Parallel } export type WorkflowEdge = Edge export type { NormalizedBlockOutput, BlockLog } diff --git a/apps/sim/lib/workflows/defaults.ts b/apps/sim/lib/workflows/defaults.ts index 7de0c058a7d..b0b6f4de2e3 100644 --- a/apps/sim/lib/workflows/defaults.ts +++ b/apps/sim/lib/workflows/defaults.ts @@ -123,8 +123,6 @@ export function buildDefaultWorkflowArtifacts(): DefaultWorkflowArtifacts { loops: {}, parallels: {}, lastSaved: Date.now(), - deploymentStatuses: {}, - needsRedeployment: false, } return { diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index ef4c00fab84..e5e02b74a6d 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -985,7 +985,6 @@ describe('Database Helpers', () => { edges: loadedState!.edges, loops: {}, parallels: {}, - deploymentStatuses: {}, } const mockTransaction = vi.fn().mockImplementation(async (callback) => { diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 60cfa177e6b..3e8eeeb99bb 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -186,7 +186,6 @@ export async function getWorkflowState(workflowId: string) { if (normalizedData) { const finalState = { - deploymentStatuses: {}, hasActiveWebhook: false, blocks: normalizedData.blocks, edges: normalizedData.edges, diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index d1bbbc9b227..fc4d647dbbe 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -203,7 +203,6 @@ export const resetAllStores = () => { useWorkflowRegistry.setState({ activeWorkflowId: null, error: null, - deploymentStatuses: {}, hydration: { phase: 'idle', workspaceId: null, diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index e2fdf9a7c4a..1571a0e50f1 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -3,7 +3,7 @@ import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Workflows') @@ -30,9 +30,6 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) { return null } - // Get deployment status from registry - const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) - // Use the current state from the store (only available for active workflow) const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState() @@ -52,110 +49,10 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) { loops: workflowState.loops, parallels: workflowState.parallels, lastSaved: workflowState.lastSaved, - // Get deployment fields from registry for API compatibility - isDeployed: deploymentStatus?.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt, }, } } -/** - * Get a specific block with its subblock values merged in - * @param blockId ID of the block to retrieve - * @returns The block with merged subblock values or null if not found - */ -export function getBlockWithValues(blockId: string): BlockState | null { - const workflowState = useWorkflowStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - if (!activeWorkflowId || !workflowState.blocks[blockId]) return null - - const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId, blockId) - return mergedBlocks[blockId] || null -} - -/** - * Get all workflows with their values merged - * Note: Since localStorage has been removed, this only includes the active workflow state - * @param workspaceId Workspace containing the workflow metadata - * @returns An object containing workflows, with state only for the active workflow - */ -export function getAllWorkflowsWithValues(workspaceId: string) { - const workflows = getWorkflows(workspaceId) - const result: Record< - string, - { - id: string - name: string - description?: string - color: string - folderId?: string | null - workspaceId?: string - apiKey?: string - state: WorkflowState & { isDeployed: boolean; deployedAt?: Date } - } - > = {} - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const currentState = useWorkflowStore.getState() - - // Only sync the active workflow to ensure we always send valid state data - const activeMetadata = activeWorkflowId - ? workflows.find((w) => w.id === activeWorkflowId) - : undefined - if (activeWorkflowId && activeMetadata) { - const metadata = activeMetadata - - // Get deployment status from registry - const deploymentStatus = useWorkflowRegistry - .getState() - .getWorkflowDeploymentStatus(activeWorkflowId) - - // Ensure state has all required fields for Zod validation - const workflowState: WorkflowState = { - ...useWorkflowStore.getState().getWorkflowState(), - // Ensure fallback values for safer handling - blocks: currentState.blocks || {}, - edges: currentState.edges || [], - loops: currentState.loops || {}, - parallels: currentState.parallels || {}, - lastSaved: currentState.lastSaved || Date.now(), - } - - // Merge the subblock values for this specific workflow - const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId) - - // Include the API key in the state if it exists in the deployment status - const apiKey = deploymentStatus?.apiKey - - result[activeWorkflowId] = { - id: activeWorkflowId, - name: metadata.name, - description: metadata.description, - color: metadata.color || '#3972F6', - folderId: metadata.folderId, - state: { - blocks: mergedBlocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - lastSaved: workflowState.lastSaved, - // Get deployment fields from registry for API compatibility - isDeployed: deploymentStatus?.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt, - }, - // Include API key if available - apiKey, - } - - // Only include workspaceId if it's not null/undefined - if (metadata.workspaceId) { - result[activeWorkflowId].workspaceId = metadata.workspaceId - } - } - - return result -} - export { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { WorkflowMetadata } from '@/stores/workflows/registry/types' export { useSubBlockStore } from '@/stores/workflows/subblock/store' diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 9ece2c4c18e..59be5bf8b3d 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -3,14 +3,12 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments' +import { deploymentKeys } from '@/hooks/queries/deployments' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { useVariablesStore } from '@/stores/panel/variables/store' import type { Variable } from '@/stores/panel/variables/types' -import type { - DeploymentStatus, - HydrationState, - WorkflowRegistry, -} from '@/stores/workflows/registry/types' +import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -34,7 +32,6 @@ function resetWorkflowStores() { edges: [], loops: {}, parallels: {}, - deploymentStatuses: {}, lastSaved: Date.now(), }) @@ -48,7 +45,6 @@ export const useWorkflowRegistry = create()( (set, get) => ({ activeWorkflowId: null, error: null, - deploymentStatuses: {}, hydration: initialHydration, clipboard: null, pendingSelection: null, @@ -61,7 +57,6 @@ export const useWorkflowRegistry = create()( set({ activeWorkflowId: null, - deploymentStatuses: {}, error: null, hydration: { phase: 'idle', @@ -73,74 +68,6 @@ export const useWorkflowRegistry = create()( }) }, - getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => { - if (!workflowId) { - workflowId = get().activeWorkflowId - if (!workflowId) return null - } - - const { deploymentStatuses = {} } = get() - - if (deploymentStatuses[workflowId]) { - return deploymentStatuses[workflowId] - } - - return null - }, - - setDeploymentStatus: ( - workflowId: string | null, - isDeployed: boolean, - deployedAt?: Date, - apiKey?: string - ) => { - if (!workflowId) { - workflowId = get().activeWorkflowId - if (!workflowId) return - } - - set((state) => ({ - deploymentStatuses: { - ...state.deploymentStatuses, - [workflowId as string]: { - isDeployed, - deployedAt: deployedAt || (isDeployed ? new Date() : undefined), - apiKey, - needsRedeployment: isDeployed - ? false - : (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false), - }, - }, - })) - }, - - setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => { - if (!workflowId) { - workflowId = get().activeWorkflowId - if (!workflowId) return - } - - set((state) => { - const deploymentStatuses = state.deploymentStatuses || {} - const currentStatus = deploymentStatuses[workflowId as string] || { isDeployed: false } - - return { - deploymentStatuses: { - ...deploymentStatuses, - [workflowId as string]: { - ...currentStatus, - needsRedeployment, - }, - }, - } - }) - - const { activeWorkflowId } = get() - if (workflowId === activeWorkflowId) { - useWorkflowStore.getState().setNeedsRedeploymentFlag(needsRedeployment) - } - }, - loadWorkflowState: async (workflowId: string) => { const workspaceId = get().hydration.workspaceId if (!workspaceId) { @@ -170,20 +97,19 @@ export const useWorkflowRegistry = create()( } const workflowData = (await response.json()).data - const nextDeploymentStatuses = - workflowData?.isDeployed || workflowData?.deployedAt - ? { - ...get().deploymentStatuses, - [workflowId]: { - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt - ? new Date(workflowData.deployedAt) - : undefined, - apiKey: workflowData.apiKey || undefined, - needsRedeployment: false, - }, - } - : get().deploymentStatuses + + if (workflowData?.isDeployed !== undefined) { + getQueryClient().setQueryData( + deploymentKeys.info(workflowId), + (prev) => ({ + isDeployed: workflowData.isDeployed ?? false, + deployedAt: workflowData.deployedAt ?? null, + apiKey: workflowData.apiKey ?? prev?.apiKey ?? null, + needsRedeployment: prev?.needsRedeployment ?? false, + isPublicApi: prev?.isPublicApi ?? false, + }) + ) + } let workflowState: WorkflowState @@ -195,7 +121,6 @@ export const useWorkflowRegistry = create()( loops: workflowData.state.loops || {}, parallels: workflowData.state.parallels || {}, lastSaved: Date.now(), - deploymentStatuses: nextDeploymentStatuses, } } else { workflowState = { @@ -204,7 +129,6 @@ export const useWorkflowRegistry = create()( edges: [], loops: {}, parallels: {}, - deploymentStatuses: nextDeploymentStatuses, lastSaved: Date.now(), } @@ -250,7 +174,6 @@ export const useWorkflowRegistry = create()( set((state) => ({ activeWorkflowId: workflowId, error: null, - deploymentStatuses: nextDeploymentStatuses, hydration: { phase: 'ready', workspaceId: state.hydration.workspaceId, @@ -367,7 +290,6 @@ export const useWorkflowRegistry = create()( set({ activeWorkflowId: null, - deploymentStatuses: {}, error: null, hydration: initialHydration, clipboard: null, diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index f434967e413..96a2d324539 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -1,13 +1,6 @@ import type { Edge } from 'reactflow' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' -export interface DeploymentStatus { - isDeployed: boolean - deployedAt?: Date - apiKey?: string - needsRedeployment?: boolean -} - export interface ClipboardData { blocks: Record edges: Edge[] @@ -45,7 +38,6 @@ export interface HydrationState { export interface WorkflowRegistryState { activeWorkflowId: string | null error: string | null - deploymentStatuses: Record hydration: HydrationState clipboard: ClipboardData | null pendingSelection: string[] | null @@ -57,14 +49,6 @@ export interface WorkflowRegistryActions { switchToWorkspace: (id: string) => void markWorkflowCreating: (workflowId: string) => void markWorkflowCreated: (workflowId: string | null) => void - getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null - setDeploymentStatus: ( - workflowId: string | null, - isDeployed: boolean, - deployedAt?: Date, - apiKey?: string - ) => void - setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void copyBlocks: (blockIds: string[]) => void preparePasteData: (positionOffset?: { x: number; y: number }) => { blocks: Record diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index de8a7b58126..e7900c5a8a2 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -107,8 +107,6 @@ const initialState = { loops: {}, parallels: {}, lastSaved: undefined, - deploymentStatuses: {}, - needsRedeployment: false, } export const useWorkflowStore = create()( @@ -116,10 +114,6 @@ export const useWorkflowStore = create()( (set, get) => ({ ...initialState, - setNeedsRedeploymentFlag: (needsRedeployment: boolean) => { - set({ needsRedeployment }) - }, - setCurrentWorkflowId: (currentWorkflowId) => { set({ currentWorkflowId }) }, @@ -540,8 +534,6 @@ export const useWorkflowStore = create()( loops: state.loops, parallels: state.parallels, lastSaved: state.lastSaved, - deploymentStatuses: state.deploymentStatuses, - needsRedeployment: state.needsRedeployment, } }, replaceWorkflowState: ( @@ -580,11 +572,6 @@ export const useWorkflowStore = create()( edges: nextEdges, loops: nextLoops, parallels: nextParallels, - deploymentStatuses: nextState.deploymentStatuses || state.deploymentStatuses, - needsRedeployment: - nextState.needsRedeployment !== undefined - ? nextState.needsRedeployment - : state.needsRedeployment, lastSaved: options?.updateLastSaved === true ? Date.now() @@ -1141,67 +1128,6 @@ export const useWorkflowStore = create()( })) }, - revertToDeployedState: async (deployedState: WorkflowState) => { - const activeWorkflowId = get().currentWorkflowId - - if (!activeWorkflowId) { - logger.error('Cannot revert: no active workflow ID') - return - } - - const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId] - - get().replaceWorkflowState({ - ...deployedState, - needsRedeployment: false, - deploymentStatuses: { - ...get().deploymentStatuses, - ...(deploymentStatus ? { [activeWorkflowId]: deploymentStatus } : {}), - }, - }) - - const values: Record> = {} - Object.entries(deployedState.blocks).forEach(([blockId, block]) => { - values[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => { - values[blockId][subBlockId] = subBlock.value - }) - }) - - useSubBlockStore.setState({ - workflowValues: { - ...useSubBlockStore.getState().workflowValues, - [activeWorkflowId]: values, - }, - }) - - get().updateLastSaved() - - // Call API to persist the revert to normalized tables - try { - const response = await fetch( - `/api/workflows/${activeWorkflowId}/deployments/active/revert`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Failed to persist revert to deployed state:', errorData.error) - // Don't throw error to avoid breaking the UI, but log it - } else { - logger.info('Successfully persisted revert to deployed state') - } - } catch (error) { - logger.error('Error calling revert to deployed API:', error) - // Don't throw error to avoid breaking the UI - } - }, - toggleBlockAdvancedMode: (id: string) => { const block = get().blocks[id] if (!block) return diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 21f22ff5478..37d86847bcb 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -1,6 +1,5 @@ import type { Edge } from 'reactflow' import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types' -import type { DeploymentStatus } from '@/stores/workflows/registry/types' export const SUBFLOW_TYPES = { LOOP: 'loop', @@ -173,8 +172,6 @@ export interface WorkflowState { exportedAt?: string } variables?: Record - deploymentStatuses?: Record - needsRedeployment?: boolean dragStartPosition?: DragStartPosition | null } @@ -228,8 +225,6 @@ export interface WorkflowActions { updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void generateLoopBlocks: () => Record generateParallelBlocks: () => Record - setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void - revertToDeployedState: (deployedState: WorkflowState) => void toggleBlockAdvancedMode: (id: string) => void setDragStartPosition: (position: DragStartPosition | null) => void getDragStartPosition: () => DragStartPosition | null diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 3ff33f1c9a0..1c7df7a07c6 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -193,8 +193,8 @@ vi.mock('@/tools/registry', () => { return { tools: mockTools } }) -// Mock custom tools - define mock data inside factory function -vi.mock('@/hooks/queries/utils/custom-tool-cache', () => { +// Mock query client for custom tool cache reads +vi.mock('@/app/_shell/providers/get-query-client', () => { const mockCustomTool = { id: 'custom-tool-123', title: 'Custom Weather Tool', @@ -214,13 +214,12 @@ vi.mock('@/hooks/queries/utils/custom-tool-cache', () => { }, } return { - getCustomTool: (toolId: string) => { - if (toolId === 'custom-tool-123') { - return mockCustomTool - } - return undefined - }, - getCustomTools: () => [mockCustomTool], + getQueryClient: () => ({ + getQueryData: (key: string[]) => { + if (key[0] === 'customTools') return [mockCustomTool] + return undefined + }, + }), } }) From 2334f2dca4933eca868bcc539a7692205aa0f7a4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 17:45:30 -0700 Subject: [PATCH 02/10] fix(loading): remove jarring workflow loading spinners (#3928) * fix(loading): remove jarring workflow loading spinners * fix(loading): remove home page skeleton loading state * fix(loading): remove plain spinner loading states from task and file view --- .../files/[fileId]/view/loading.tsx | 9 -------- .../workspace/[workspaceId]/home/loading.tsx | 22 ------------------- .../[workspaceId]/task/[taskId]/loading.tsx | 13 ----------- .../[workspaceId]/w/[workflowId]/loading.tsx | 11 ---------- .../app/workspace/[workspaceId]/w/loading.tsx | 11 ---------- 5 files changed, 66 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/loading.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/loading.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/task/[taskId]/loading.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/loading.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/loading.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/loading.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/loading.tsx deleted file mode 100644 index c64c3a39e27..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Loader2 } from 'lucide-react' - -export default function FileViewLoading() { - return ( -
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/loading.tsx b/apps/sim/app/workspace/[workspaceId]/home/loading.tsx deleted file mode 100644 index 98b656ef94e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/loading.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Skeleton } from '@/components/emcn' - -const SKELETON_LINE_COUNT = 4 - -export default function HomeLoading() { - return ( -
-
-
- {Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => ( - - ))} -
-
-
-
- -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/task/[taskId]/loading.tsx deleted file mode 100644 index acc047d3a4b..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/task/[taskId]/loading.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Loader2 } from 'lucide-react' - -export default function TaskLoading() { - return ( -
-
-
- -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/loading.tsx deleted file mode 100644 index 6a65da73c7e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Loader2 } from 'lucide-react' - -export default function WorkflowLoading() { - return ( -
-
- -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/loading.tsx b/apps/sim/app/workspace/[workspaceId]/w/loading.tsx deleted file mode 100644 index 9bb97126c6a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Loader2 } from 'lucide-react' - -export default function WorkflowsLoading() { - return ( -
-
- -
-
- ) -} From 34d210c66c86ebf7bde3f3c9288a20d99231e534 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 17:54:49 -0700 Subject: [PATCH 03/10] chore(stores): remove Zustand environment store and dead init scaffolding (#3929) --- .../sub-block/hooks/use-selector-setup.ts | 4 +- .../components/variables/variables.tsx | 2 +- .../hooks/use-workflow-execution.ts | 3 - apps/sim/hooks/queries/environment.ts | 17 +- .../sim/hooks/selectors/use-selector-query.ts | 4 +- apps/sim/stores/index.ts | 224 ++------------ apps/sim/stores/panel/variables/store.ts | 28 -- apps/sim/stores/panel/variables/types.ts | 5 - apps/sim/stores/settings/environment/index.ts | 8 +- apps/sim/stores/settings/environment/store.ts | 43 --- apps/sim/stores/settings/environment/types.ts | 7 - apps/sim/stores/variables/store.ts | 283 +----------------- apps/sim/stores/variables/types.ts | 37 +-- apps/sim/tools/utils.test.ts | 51 ++-- apps/sim/tools/utils.ts | 28 +- 15 files changed, 73 insertions(+), 671 deletions(-) delete mode 100644 apps/sim/stores/settings/environment/store.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index 3986164f0fd..555496c899a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -5,8 +5,8 @@ import { useParams } from 'next/navigation' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import type { SubBlockConfig } from '@/blocks/types' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' +import { usePersonalEnvironment } from '@/hooks/queries/environment' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' -import { useEnvironmentStore } from '@/stores/settings/environment' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useDependsOnGate } from './use-depends-on-gate' import { useSubBlockValue } from './use-sub-block-value' @@ -32,7 +32,7 @@ export function useSelectorSetup( const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const workflowId = (params?.workflowId as string) || activeWorkflowId || '' - const envVariables = useEnvironmentStore((s) => s.variables) + const { data: envVariables = {} } = usePersonalEnvironment() const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate( blockId, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index 7c182e285c6..fb43bd5c3d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -28,6 +28,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel' +import type { Variable } from '@/stores/panel/variables/types' import { getVariablesPosition, MAX_VARIABLES_HEIGHT, @@ -36,7 +37,6 @@ import { MIN_VARIABLES_WIDTH, useVariablesStore, } from '@/stores/variables/store' -import type { Variable } from '@/stores/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 2e8e4b8ce4a..f298bef0248 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -37,7 +37,6 @@ import { WorkflowValidationError } from '@/serializer' import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution' import { useNotificationStore } from '@/stores/notifications' import { useVariablesStore } from '@/stores/panel' -import { useEnvironmentStore } from '@/stores/settings/environment' import { clearExecutionPointer, consolePersistence, @@ -120,7 +119,6 @@ export function useWorkflowExecution() { })) ) const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated) - const getAllVariables = useEnvironmentStore((s) => s.getAllVariables) const { getVariablesByWorkflowId, variables } = useVariablesStore( useShallow((s) => ({ getVariablesByWorkflowId: s.getVariablesByWorkflowId, @@ -744,7 +742,6 @@ export function useWorkflowExecution() { activeWorkflowId, currentWorkflow, toggleConsole, - getAllVariables, getVariablesByWorkflowId, setIsExecuting, setIsDebugging, diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 01f6f6b9929..6d9c2dab94a 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -1,4 +1,3 @@ -import { useEffect } from 'react' import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' @@ -6,7 +5,6 @@ import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/envir import { workspaceCredentialKeys } from '@/hooks/queries/credentials' import { API_ENDPOINTS } from '@/stores/constants' import type { EnvironmentVariable } from '@/stores/settings/environment' -import { useEnvironmentStore } from '@/stores/settings/environment' export type { WorkspaceEnvironmentData } from '@/lib/environment/api' export type { EnvironmentVariable } from '@/stores/settings/environment' @@ -22,29 +20,16 @@ export const environmentKeys = { workspace: (workspaceId: string) => [...environmentKeys.all, 'workspace', workspaceId] as const, } -/** - * Environment Variable Types - */ /** * Hook to fetch personal environment variables */ export function usePersonalEnvironment() { - const setVariables = useEnvironmentStore((state) => state.setVariables) - - const query = useQuery({ + return useQuery({ queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), staleTime: 60 * 1000, // 1 minute placeholderData: keepPreviousData, }) - - useEffect(() => { - if (query.data) { - setVariables(query.data) - } - }, [query.data, setVariables]) - - return query } /** diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 0323c7f5e7d..a4444b762aa 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' +import { usePersonalEnvironment } from '@/hooks/queries/environment' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types' -import { useEnvironmentStore } from '@/stores/settings/environment' interface SelectorHookArgs extends Omit { search?: string @@ -31,7 +31,7 @@ export function useSelectorOptionDetail( key: SelectorKey, args: SelectorHookArgs & { detailId?: string } ) { - const envVariables = useEnvironmentStore((s) => s.variables) + const { data: envVariables = {} } = usePersonalEnvironment() const definition = getSelectorDefinition(key) const resolvedDetailId = useMemo(() => { diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index fc4d647dbbe..46535c23a6c 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -1,10 +1,9 @@ 'use client' -import { useEffect } from 'react' import { createLogger } from '@sim/logger' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { environmentKeys } from '@/hooks/queries/environment' import { useExecutionStore } from '@/stores/execution' -import { useVariablesStore } from '@/stores/panel' -import { useEnvironmentStore } from '@/stores/settings/environment' import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -12,194 +11,10 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('Stores') -// Track initialization state -let isInitializing = false -let appFullyInitialized = false -let dataInitialized = false // Flag for actual data loading completion - -/** - * Initialize the application state and sync system - * localStorage persistence has been removed - relies on DB and Zustand stores only - */ -async function initializeApplication(): Promise { - if (typeof window === 'undefined' || isInitializing) return - - isInitializing = true - appFullyInitialized = false - - // Track initialization start time - const initStartTime = Date.now() - - try { - // Load environment variables directly from DB - await useEnvironmentStore.getState().loadEnvironmentVariables() - - // Mark data as initialized only after sync managers have loaded data from DB - dataInitialized = true - - // Log initialization timing information - const initDuration = Date.now() - initStartTime - logger.info(`Application initialization completed in ${initDuration}ms`) - - // Mark application as fully initialized - appFullyInitialized = true - } catch (error) { - logger.error('Error during application initialization:', { error }) - // Still mark as initialized to prevent being stuck in initializing state - appFullyInitialized = true - // But don't mark data as initialized on error - dataInitialized = false - } finally { - isInitializing = false - } -} - -/** - * Checks if application is fully initialized - */ -export function isAppInitialized(): boolean { - return appFullyInitialized -} - -/** - * Checks if data has been loaded from the database - * This should be checked before any sync operations - */ -export function isDataInitialized(): boolean { - return dataInitialized -} - -/** - * Handle application cleanup before unload - */ -function handleBeforeUnload(event: BeforeUnloadEvent): void { - // Check if we're on an authentication page and skip confirmation if we are - if (typeof window !== 'undefined') { - const path = window.location.pathname - // Skip confirmation for auth-related pages - if ( - path === '/login' || - path === '/signup' || - path === '/reset-password' || - path === '/verify' - ) { - return - } - } - - // Standard beforeunload pattern - event.preventDefault() - event.returnValue = '' -} - -/** - * Clean up sync system - */ -function cleanupApplication(): void { - window.removeEventListener('beforeunload', handleBeforeUnload) - // Note: No sync managers to dispose - Socket.IO handles cleanup -} - -/** - * Clear all user data when signing out - * localStorage persistence has been removed - */ -export async function clearUserData(): Promise { - if (typeof window === 'undefined') return - - try { - // Note: No sync managers to dispose - Socket.IO handles cleanup - - // Reset all stores to their initial state - resetAllStores() - - // Clear localStorage except for essential app settings (minimal usage) - const keysToKeep = ['next-favicon', 'theme'] - const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) - keysToRemove.forEach((key) => localStorage.removeItem(key)) - - // Reset application initialization state - appFullyInitialized = false - dataInitialized = false - - logger.info('User data cleared successfully') - } catch (error) { - logger.error('Error clearing user data:', { error }) - } -} - -/** - * Hook to manage application lifecycle - */ -export function useAppInitialization() { - useEffect(() => { - // Use Promise to handle async initialization - initializeApplication() - - return () => { - cleanupApplication() - } - }, []) -} - -/** - * Hook to reinitialize the application after successful login - * Use this in the login success handler or post-login page - */ -export function useLoginInitialization() { - useEffect(() => { - reinitializeAfterLogin() - }, []) -} - /** - * Reinitialize the application after login - * This ensures we load fresh data from the database for the new user + * Reset all Zustand stores and React Query caches to initial state. */ -export async function reinitializeAfterLogin(): Promise { - if (typeof window === 'undefined') return - - try { - // Reset application initialization state - appFullyInitialized = false - dataInitialized = false - - // Note: No sync managers to dispose - Socket.IO handles cleanup - - // Clean existing state to avoid stale data - resetAllStores() - - // Reset initialization flags to force a fresh load - isInitializing = false - - // Reinitialize the application - await initializeApplication() - - logger.info('Application reinitialized after login') - } catch (error) { - logger.error('Error reinitializing application:', { error }) - } -} - -// Initialize immediately when imported on client -if (typeof window !== 'undefined') { - initializeApplication() -} - -// Export all stores -export { - useWorkflowStore, - useWorkflowRegistry, - useEnvironmentStore, - useExecutionStore, - useTerminalConsoleStore, - useVariablesStore, - useSubBlockStore, -} - -// Helper function to reset all stores export const resetAllStores = () => { - // Reset all stores to initial state useWorkflowRegistry.setState({ activeWorkflowId: null, error: null, @@ -213,7 +28,7 @@ export const resetAllStores = () => { }) useWorkflowStore.getState().clear() useSubBlockStore.getState().clear() - useEnvironmentStore.getState().reset() + getQueryClient().removeQueries({ queryKey: environmentKeys.all }) useExecutionStore.getState().reset() useTerminalConsoleStore.setState({ workflowEntries: {}, @@ -222,21 +37,24 @@ export const resetAllStores = () => { isOpen: false, }) consolePersistence.persist() - // Custom tools are managed by React Query cache, not a Zustand store - // Variables store has no tracking to reset; registry hydrates } -// Helper function to log all store states -export const logAllStores = () => { - const state = { - workflow: useWorkflowStore.getState(), - workflowRegistry: useWorkflowRegistry.getState(), - environment: useEnvironmentStore.getState(), - execution: useExecutionStore.getState(), - console: useTerminalConsoleStore.getState(), - subBlock: useSubBlockStore.getState(), - variables: useVariablesStore.getState(), - } +/** + * Clear all user data when signing out. + */ +export async function clearUserData(): Promise { + if (typeof window === 'undefined') return - return state + try { + resetAllStores() + + // Clear localStorage except for essential app settings + const keysToKeep = ['next-favicon', 'theme'] + const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) + keysToRemove.forEach((key) => localStorage.removeItem(key)) + + logger.info('User data cleared successfully') + } catch (error) { + logger.error('Error clearing user data:', { error }) + } } diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index e9a7db871c5..dbd8145ba55 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -78,34 +78,6 @@ export const useVariablesStore = create()( error: null, isEditing: null, - async loadForWorkflow(workflowId) { - try { - set({ isLoading: true, error: null }) - const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(text || `Failed to load variables: ${res.statusText}`) - } - const data = await res.json() - const variables = (data?.data as Record) || {} - set((state) => { - const withoutWorkflow = Object.fromEntries( - Object.entries(state.variables).filter( - (entry): entry is [string, Variable] => entry[1].workflowId !== workflowId - ) - ) - return { - variables: { ...withoutWorkflow, ...variables }, - isLoading: false, - error: null, - } - }) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - set({ isLoading: false, error: message }) - } - }, - addVariable: (variable, providedId?: string) => { const id = providedId || crypto.randomUUID() diff --git a/apps/sim/stores/panel/variables/types.ts b/apps/sim/stores/panel/variables/types.ts index c0f7d06d150..7cdfb60d430 100644 --- a/apps/sim/stores/panel/variables/types.ts +++ b/apps/sim/stores/panel/variables/types.ts @@ -23,11 +23,6 @@ export interface VariablesStore { error: string | null isEditing: string | null - /** - * Loads variables for a specific workflow from the API and hydrates the store. - */ - loadForWorkflow: (workflowId: string) => Promise - /** * Adds a new variable with automatic name uniqueness validation * If a variable with the same name exists, it will be suffixed with a number diff --git a/apps/sim/stores/settings/environment/index.ts b/apps/sim/stores/settings/environment/index.ts index 0b13cd29f71..01e93a50d17 100644 --- a/apps/sim/stores/settings/environment/index.ts +++ b/apps/sim/stores/settings/environment/index.ts @@ -1,7 +1 @@ -export { useEnvironmentStore } from './store' -export type { - CachedWorkspaceEnvData, - EnvironmentState, - EnvironmentStore, - EnvironmentVariable, -} from './types' +export type { CachedWorkspaceEnvData, EnvironmentState, EnvironmentVariable } from './types' diff --git a/apps/sim/stores/settings/environment/store.ts b/apps/sim/stores/settings/environment/store.ts deleted file mode 100644 index 99bb11c18fa..00000000000 --- a/apps/sim/stores/settings/environment/store.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createLogger } from '@sim/logger' -import { create } from 'zustand' -import { fetchPersonalEnvironment } from '@/lib/environment/api' -import type { EnvironmentStore, EnvironmentVariable } from './types' - -const logger = createLogger('EnvironmentStore') - -export const useEnvironmentStore = create()((set, get) => ({ - variables: {}, - isLoading: false, - error: null, - - loadEnvironmentVariables: async () => { - try { - set({ isLoading: true, error: null }) - const data = await fetchPersonalEnvironment() - set({ variables: data, isLoading: false }) - } catch (error) { - logger.error('Error loading environment variables:', { error }) - set({ - error: error instanceof Error ? error.message : 'Unknown error', - isLoading: false, - }) - throw error - } - }, - - setVariables: (variables: Record) => { - set({ variables }) - }, - - getAllVariables: () => { - return get().variables - }, - - reset: () => { - set({ - variables: {}, - isLoading: false, - error: null, - }) - }, -})) diff --git a/apps/sim/stores/settings/environment/types.ts b/apps/sim/stores/settings/environment/types.ts index 7e9a575a8dd..8dbb67caf77 100644 --- a/apps/sim/stores/settings/environment/types.ts +++ b/apps/sim/stores/settings/environment/types.ts @@ -15,10 +15,3 @@ export interface EnvironmentState { isLoading: boolean error: string | null } - -export interface EnvironmentStore extends EnvironmentState { - loadEnvironmentVariables: () => Promise - setVariables: (variables: Record) => void - getAllVariables: () => Record - reset: () => void -} diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index 497db6550bb..47a9039ae18 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -1,20 +1,10 @@ -import { createLogger } from '@sim/logger' -import JSON5 from 'json5' -import { v4 as uuidv4 } from 'uuid' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' -import { normalizeName } from '@/executor/constants' import type { - Variable, VariablesDimensions, + VariablesModalStore, VariablesPosition, - VariablesStore, - VariableType, } from '@/stores/variables/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -const logger = createLogger('VariablesModalStore') /** * Floating variables modal default dimensions. @@ -25,7 +15,6 @@ const DEFAULT_HEIGHT = 320 /** * Minimum and maximum modal dimensions. - * Kept in sync with the chat modal experience. */ export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT @@ -110,70 +99,13 @@ export const getVariablesPosition = ( } /** - * Validate a variable's value given its type. Returns an error message or undefined. - */ -function validateVariable(variable: Variable): string | undefined { - try { - switch (variable.type) { - case 'number': { - return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined - } - case 'boolean': { - return !/^(true|false)$/i.test(String(variable.value).trim()) - ? 'Expected "true" or "false"' - : undefined - } - case 'object': { - try { - const valueToEvaluate = String(variable.value).trim() - if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) { - return 'Not a valid object format' - } - const parsed = JSON5.parse(valueToEvaluate) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return 'Not a valid object' - } - return undefined - } catch (e) { - logger.error('Object parsing error:', e) - return 'Invalid object syntax' - } - } - case 'array': { - try { - const parsed = JSON5.parse(String(variable.value)) - if (!Array.isArray(parsed)) { - return 'Not a valid array' - } - } catch { - return 'Invalid array syntax' - } - return undefined - } - default: - return undefined - } - } catch (e) { - return e instanceof Error ? e.message : 'Invalid format' - } -} - -/** - * Migrate deprecated type 'string' -> 'plain'. + * UI-only store for the floating variables modal. + * Variable data lives in the panel variables store (`@/stores/panel/variables`). */ -function migrateStringToPlain(variable: Variable): Variable { - if (variable.type !== 'string') return variable - return { ...variable, type: 'plain' as const } -} - -/** - * Floating Variables modal + Variables data store. - */ -export const useVariablesStore = create()( +export const useVariablesStore = create()( devtools( persist( - (set, get) => ({ - // UI + (set) => ({ isOpen: false, position: null, width: DEFAULT_WIDTH, @@ -190,208 +122,17 @@ export const useVariablesStore = create()( ), }), resetPosition: () => set({ position: null }), - - // Data - variables: {}, - isLoading: false, - error: null, - - async loadForWorkflow(workflowId) { - try { - set({ isLoading: true, error: null }) - const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(text || `Failed to load variables: ${res.statusText}`) - } - const data = await res.json() - const variables = (data?.data as Record) || {} - // Migrate any deprecated types and merge into store (remove other workflow entries) - const migrated: Record = Object.fromEntries( - Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)]) - ) - set((state) => { - const withoutThisWorkflow = Object.fromEntries( - Object.entries(state.variables).filter( - (entry): entry is [string, Variable] => entry[1].workflowId !== workflowId - ) - ) - return { - variables: { ...withoutThisWorkflow, ...migrated }, - isLoading: false, - error: null, - } - }) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - set({ isLoading: false, error: message }) - } - }, - - addVariable: (variable, providedId) => { - const id = providedId || uuidv4() - const state = get() - - const workflowVariables = state - .getVariablesByWorkflowId(variable.workflowId) - .map((v) => ({ id: v.id, name: v.name })) - - // Default naming: variableN - if (!variable.name || /^variable\d+$/.test(variable.name)) { - const existingNumbers = workflowVariables - .map((v) => { - const match = v.name.match(/^variable(\d+)$/) - return match ? Number.parseInt(match[1]) : 0 - }) - .filter((n) => !Number.isNaN(n)) - const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 - variable.name = `variable${nextNumber}` - } - - // Ensure uniqueness - let uniqueName = variable.name - let nameIndex = 1 - while (workflowVariables.some((v) => v.name === uniqueName)) { - uniqueName = `${variable.name} (${nameIndex})` - nameIndex++ - } - - if (variable.type === 'string') { - variable.type = 'plain' - } - - const newVariable: Variable = { - id, - workflowId: variable.workflowId, - name: uniqueName, - type: variable.type, - value: variable.value ?? '', - validationError: undefined, - } - - const validationError = validateVariable(newVariable) - if (validationError) { - newVariable.validationError = validationError - } - - set((state) => ({ - variables: { - ...state.variables, - [id]: newVariable, - }, - })) - - return id - }, - - updateVariable: (id, update) => { - set((state) => { - const existing = state.variables[id] - if (!existing) return state - - // Handle name changes: keep references in sync across workflow values - if (update.name !== undefined) { - const oldVariableName = existing.name - const newName = String(update.name).trim() - - if (!newName) { - update = { ...update, name: undefined } - } else if (newName !== oldVariableName) { - const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - if (activeWorkflowId) { - const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {} - const updatedWorkflowValues = { ...workflowValues } - - Object.entries(workflowValues).forEach(([blockId, blockValues]) => { - Object.entries(blockValues as Record).forEach( - ([subBlockId, value]) => { - const oldVarName = normalizeName(oldVariableName) - const newVarName = normalizeName(newName) - const regex = new RegExp(``, 'gi') - - updatedWorkflowValues[blockId][subBlockId] = updateReferences( - value, - regex, - `` - ) - - function updateReferences( - val: any, - refRegex: RegExp, - replacement: string - ): any { - if (typeof val === 'string') { - return refRegex.test(val) ? val.replace(refRegex, replacement) : val - } - if (Array.isArray(val)) { - return val.map((item) => updateReferences(item, refRegex, replacement)) - } - if (val !== null && typeof val === 'object') { - const result: Record = { ...val } - for (const key in result) { - result[key] = updateReferences(result[key], refRegex, replacement) - } - return result - } - return val - } - } - ) - }) - - useSubBlockStore.setState({ - workflowValues: { - ...subBlockStore.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, - }, - }) - } - } - } - - // Handle deprecated -> new type migration - if (update.type === 'string') { - update = { ...update, type: 'plain' as VariableType } - } - - const updated: Variable = { - ...existing, - ...update, - validationError: undefined, - } - - // Validate only when type or value changed - if (update.type || update.value !== undefined) { - updated.validationError = validateVariable(updated) - } - - return { - variables: { - ...state.variables, - [id]: updated, - }, - } - }) - }, - - deleteVariable: (id) => { - set((state) => { - if (!state.variables[id]) return state - const { [id]: _deleted, ...rest } = state.variables - return { variables: rest } - }) - }, - - getVariablesByWorkflowId: (workflowId) => { - return Object.values(get().variables).filter((v) => v.workflowId === workflowId) - }, }), { name: 'variables-modal-store', + partialize: (state) => ({ + position: state.position, + width: state.width, + height: state.height, + }), } - ) + ), + { name: 'variables-modal-store' } ) ) diff --git a/apps/sim/stores/variables/types.ts b/apps/sim/stores/variables/types.ts index 610192f49d8..89ed0fd62fb 100644 --- a/apps/sim/stores/variables/types.ts +++ b/apps/sim/stores/variables/types.ts @@ -1,21 +1,3 @@ -/** - * Variable types supported by the variables modal/editor. - * Note: 'string' is deprecated. Use 'plain' for freeform text values instead. - */ -export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string' - -/** - * Workflow-scoped variable model. - */ -export interface Variable { - id: string - workflowId: string - name: string - type: VariableType - value: unknown - validationError?: string -} - /** * 2D position used by the floating variables modal. */ @@ -33,11 +15,10 @@ export interface VariablesDimensions { } /** - * Public store interface for variables editor/modal. - * Combines UI state of the floating modal and the variables data/actions. + * UI-only store interface for the floating variables modal. + * Variable data lives in the panel variables store (`@/stores/panel/variables`). */ -export interface VariablesStore { - // UI State +export interface VariablesModalStore { isOpen: boolean position: VariablesPosition | null width: number @@ -46,16 +27,4 @@ export interface VariablesStore { setPosition: (position: VariablesPosition) => void setDimensions: (dimensions: VariablesDimensions) => void resetPosition: () => void - - // Data - variables: Record - isLoading: boolean - error: string | null - - // Actions - loadForWorkflow: (workflowId: string) => Promise - addVariable: (variable: Omit, providedId?: string) => string - updateVariable: (id: string, update: Partial>) => void - deleteVariable: (id: string) => void - getVariablesByWorkflowId: (workflowId: string) => Variable[] } diff --git a/apps/sim/tools/utils.test.ts b/apps/sim/tools/utils.test.ts index 43a5531da91..9d8fa28f2f1 100644 --- a/apps/sim/tools/utils.test.ts +++ b/apps/sim/tools/utils.test.ts @@ -21,24 +21,23 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ secureFetchWithPinnedIP: vi.fn(), })) -vi.mock('@/stores/settings/environment', () => { - const mockStore = { - getAllVariables: vi.fn().mockReturnValue({ - API_KEY: { value: 'mock-api-key' }, - BASE_URL: { value: 'https://example.com' }, - }), - } - - return { - useEnvironmentStore: { - getState: vi.fn().mockImplementation(() => mockStore), - }, - } -}) +const { mockGetQueryData } = vi.hoisted(() => ({ + mockGetQueryData: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: () => ({ + getQueryData: mockGetQueryData, + }), +})) const originalWindow = global.window beforeEach(() => { global.window = {} as any + mockGetQueryData.mockReturnValue({ + API_KEY: { key: 'API_KEY', value: 'mock-api-key' }, + BASE_URL: { key: 'BASE_URL', value: 'https://example.com' }, + }) }) afterEach(() => { @@ -651,15 +650,8 @@ describe('createParamSchema', () => { }) describe('getClientEnvVars', () => { - it.concurrent('should return environment variables from store in browser environment', () => { - const mockStoreGetter = () => ({ - getAllVariables: () => ({ - API_KEY: { value: 'mock-api-key' }, - BASE_URL: { value: 'https://example.com' }, - }), - }) - - const result = getClientEnvVars(mockStoreGetter) + it('should return environment variables from React Query cache in browser environment', () => { + const result = getClientEnvVars() expect(result).toEqual({ API_KEY: 'mock-api-key', @@ -667,7 +659,7 @@ describe('getClientEnvVars', () => { }) }) - it.concurrent('should return empty object in server environment', () => { + it('should return empty object in server environment', () => { global.window = undefined as any const result = getClientEnvVars() @@ -677,7 +669,7 @@ describe('getClientEnvVars', () => { }) describe('createCustomToolRequestBody', () => { - it.concurrent('should create request body function for client-side execution', () => { + it('should create request body function for client-side execution', () => { const customTool = { code: 'return a + b', schema: { @@ -687,14 +679,7 @@ describe('createCustomToolRequestBody', () => { }, } - const mockStoreGetter = () => ({ - getAllVariables: () => ({ - API_KEY: { value: 'mock-api-key' }, - BASE_URL: { value: 'https://example.com' }, - }), - }) - - const bodyFn = createCustomToolRequestBody(customTool, true, undefined, mockStoreGetter) + const bodyFn = createCustomToolRequestBody(customTool, true) const result = bodyFn({ a: 5, b: 3 }) expect(result).toEqual({ diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 2f944c18bd4..534dc51797c 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -1,7 +1,9 @@ import { createLogger } from '@sim/logger' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' -import { useEnvironmentStore } from '@/stores/settings/environment' +import { environmentKeys } from '@/hooks/queries/environment' +import type { EnvironmentVariable } from '@/stores/settings/environment' import { tools } from '@/tools/registry' import type { ToolConfig } from '@/tools/types' @@ -215,20 +217,20 @@ export function createParamSchema(customTool: any): Record { } /** - * Get environment variables from store (client-side only) - * @param getStore Optional function to get the store (useful for testing) + * Get environment variables from React Query cache (client-side only) */ -export function getClientEnvVars(getStore?: () => any): Record { +export function getClientEnvVars(): Record { if (typeof window === 'undefined') return {} try { - // Allow injecting the store for testing - const envStore = getStore ? getStore() : useEnvironmentStore.getState() - const allEnvVars = envStore.getAllVariables() + const allEnvVars = + getQueryClient().getQueryData>( + environmentKeys.personal() + ) ?? {} // Convert environment variables to a simple key-value object return Object.entries(allEnvVars).reduce( - (acc, [key, variable]: [string, any]) => { + (acc, [key, variable]) => { acc[key] = variable.value return acc }, @@ -245,20 +247,14 @@ export function getClientEnvVars(getStore?: () => any): Record { * @param customTool The custom tool configuration * @param isClient Whether running on client side * @param workflowId Optional workflow ID for server-side - * @param getStore Optional function to get the store (useful for testing) */ -export function createCustomToolRequestBody( - customTool: any, - isClient = true, - workflowId?: string, - getStore?: () => any -) { +export function createCustomToolRequestBody(customTool: any, isClient = true, workflowId?: string) { return (params: Record) => { // Get environment variables - try multiple sources in order of preference: // 1. envVars parameter (passed from provider/agent context) // 2. Client-side store (if running in browser) // 3. Empty object (fallback) - const envVars = params.envVars || (isClient ? getClientEnvVars(getStore) : {}) + const envVars = params.envVars || (isClient ? getClientEnvVars() : {}) // Get workflow variables from params (passed from execution context) const workflowVariables = params.workflowVariables || {} From 98fe4cd40b205f76cfcb3d36db24ae70d591f6d8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 18:43:47 -0700 Subject: [PATCH 04/10] refactor(stores): consolidate variables stores into stores/variables/ (#3930) * refactor(stores): consolidate variables stores into stores/variables/ Move variable data store from stores/panel/variables/ to stores/variables/ since the panel variables tab no longer exists. Rename the modal UI store to useVariablesModalStore to eliminate naming collision with the data store. Co-Authored-By: Claude Opus 4.6 * fix: remove unused workflowId variable in deleteVariable Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../app/api/workflows/[id]/variables/route.ts | 2 +- .../deploy/hooks/use-change-detection.ts | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 4 +- .../variables-input/variables-input.tsx | 4 +- .../w/[workflowId]/components/panel/panel.tsx | 7 +- .../components/variables/variables.tsx | 12 +- .../workflow-block/workflow-block.tsx | 2 +- .../hooks/use-workflow-execution.ts | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 6 +- .../components/block/block.tsx | 2 +- apps/sim/hooks/use-collaborative-workflow.ts | 3 +- .../lib/workflows/persistence/duplicate.ts | 2 +- .../workflows/variables/variable-manager.ts | 2 +- apps/sim/stores/panel/index.ts | 3 - apps/sim/stores/panel/variables/index.ts | 2 - apps/sim/stores/panel/variables/store.ts | 262 ------------ apps/sim/stores/panel/variables/types.ts | 45 --- apps/sim/stores/variables/index.ts | 11 + apps/sim/stores/variables/modal.ts | 145 +++++++ apps/sim/stores/variables/store.ts | 375 +++++++++++------- apps/sim/stores/variables/types.ts | 48 ++- apps/sim/stores/workflows/registry/store.ts | 4 +- 22 files changed, 470 insertions(+), 475 deletions(-) delete mode 100644 apps/sim/stores/panel/variables/index.ts delete mode 100644 apps/sim/stores/panel/variables/store.ts delete mode 100644 apps/sim/stores/panel/variables/types.ts create mode 100644 apps/sim/stores/variables/index.ts create mode 100644 apps/sim/stores/variables/modal.ts diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 7b19a0320a9..1b4cd8ab3b9 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' -import type { Variable } from '@/stores/panel/variables/types' +import type { Variable } from '@/stores/variables/types' const logger = createLogger('WorkflowVariablesAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 7187bf7fc95..41b394b8a77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' -import { useVariablesStore } from '@/stores/panel/variables/store' +import { useVariablesStore } from '@/stores/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 68256664a9c..620d38c2e83 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -31,8 +31,8 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/ import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import { normalizeName } from '@/executor/constants' -import type { Variable } from '@/stores/panel' -import { useVariablesStore } from '@/stores/panel' +import { useVariablesStore } from '@/stores/variables/store' +import type { Variable } from '@/stores/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index 3cb55879312..c71a48f77bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -19,8 +19,8 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' -import type { Variable } from '@/stores/panel' -import { useVariablesStore } from '@/stores/panel' +import { useVariablesStore } from '@/stores/variables/store' +import type { Variable } from '@/stores/variables/types' interface VariableAssignment { id: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 0f349d1b85f..cc36b7cf9eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -63,7 +63,8 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useChatStore } from '@/stores/chat/store' import { useNotificationStore } from '@/stores/notifications/store' import type { ChatContext, PanelTab } from '@/stores/panel' -import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel' +import { usePanelStore } from '@/stores/panel' +import { useVariablesModalStore } from '@/stores/variables/modal' import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils' @@ -205,7 +206,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsChatOpen: state.setIsChatOpen, })) ) - const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore( + const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore( useShallow((state) => ({ isOpen: state.isOpen, setIsOpen: state.setIsOpen, @@ -482,7 +483,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel throw new Error('No workflow state found') } - const workflowVariables = usePanelVariablesStore + const workflowVariables = useVariablesStore .getState() .getVariablesByWorkflowId(activeWorkflowId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx index fb43bd5c3d7..a5fb1615c45 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables.tsx @@ -27,16 +27,16 @@ import { usePreventZoom, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel' -import type { Variable } from '@/stores/panel/variables/types' import { getVariablesPosition, MAX_VARIABLES_HEIGHT, MAX_VARIABLES_WIDTH, MIN_VARIABLES_HEIGHT, MIN_VARIABLES_WIDTH, - useVariablesStore, -} from '@/stores/variables/store' + useVariablesModalStore, +} from '@/stores/variables/modal' +import { useVariablesStore } from '@/stores/variables/store' +import type { Variable } from '@/stores/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** @@ -96,7 +96,7 @@ export function Variables() { const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } = - useVariablesStore( + useVariablesModalStore( useShallow((s) => ({ isOpen: s.isOpen, position: s.position, @@ -108,7 +108,7 @@ export function Variables() { })) ) - const variables = usePanelVariablesStore((s) => s.variables) + const variables = useVariablesStore((s) => s.variables) const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } = useCollaborativeWorkflow() 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 b7b04f38bb2..bb676157396 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 @@ -48,7 +48,7 @@ import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' -import { useVariablesStore } from '@/stores/panel' +import { useVariablesStore } from '@/stores/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { wouldCreateCycle } from '@/stores/workflows/workflow/utils' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index f298bef0248..8b9ca1fa0a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -36,7 +36,6 @@ import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution' import { useNotificationStore } from '@/stores/notifications' -import { useVariablesStore } from '@/stores/panel' import { clearExecutionPointer, consolePersistence, @@ -44,6 +43,7 @@ import { saveExecutionPointer, useTerminalConsoleStore, } from '@/stores/terminal' +import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 57fd7a41706..a94d4aa9607 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -85,7 +85,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store' import { useNotificationStore } from '@/stores/notifications' import { usePanelEditorStore } from '@/stores/panel' import { useUndoRedoStore } from '@/stores/undo-redo' -import { useVariablesStore } from '@/stores/variables/store' +import { useVariablesModalStore } from '@/stores/variables/modal' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils' @@ -337,7 +337,7 @@ const WorkflowContent = React.memo( autoConnectRef.current = isAutoConnectEnabled // Panel open states for context menu - const isVariablesOpen = useVariablesStore((state) => state.isOpen) + const isVariablesOpen = useVariablesModalStore((state) => state.isOpen) const isChatOpen = useChatStore((state) => state.isChatOpen) const snapGrid: [number, number] = useMemo( @@ -1374,7 +1374,7 @@ const WorkflowContent = React.memo( }, [router, workspaceId, workflowIdParam]) const handleContextToggleVariables = useCallback(() => { - const { isOpen, setIsOpen } = useVariablesStore.getState() + const { isOpen, setIsOpen } = useVariablesModalStore.getState() setIsOpen(!isOpen) }, []) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index f2c78c680c3..f6c0f9c5b5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -13,7 +13,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' -import { useVariablesStore } from '@/stores/panel/variables/store' +import { useVariablesStore } from '@/stores/variables/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** Execution status for blocks in preview mode */ diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 0b22a47de4b..b878b89a274 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -19,8 +19,9 @@ import { } from '@/socket/constants' import { useNotificationStore } from '@/stores/notifications' import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store' -import { usePanelEditorStore, useVariablesStore } from '@/stores/panel' +import { usePanelEditorStore } from '@/stores/panel' import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo' +import { useVariablesStore } from '@/stores/variables/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 1895068ab4d..bbc07a5ad15 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -14,7 +14,7 @@ import { deduplicateWorkflowName, } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import type { Variable } from '@/stores/panel/variables/types' +import type { Variable } from '@/stores/variables/types' import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDuplicateHelper') diff --git a/apps/sim/lib/workflows/variables/variable-manager.ts b/apps/sim/lib/workflows/variables/variable-manager.ts index 7807d466c0e..61e5dfaee39 100644 --- a/apps/sim/lib/workflows/variables/variable-manager.ts +++ b/apps/sim/lib/workflows/variables/variable-manager.ts @@ -1,4 +1,4 @@ -import type { VariableType } from '@/stores/panel/variables/types' +import type { VariableType } from '@/stores/variables/types' /** * Central manager for handling all variable-related operations. diff --git a/apps/sim/stores/panel/index.ts b/apps/sim/stores/panel/index.ts index 13ec5f1eec3..cf2c541411f 100644 --- a/apps/sim/stores/panel/index.ts +++ b/apps/sim/stores/panel/index.ts @@ -9,6 +9,3 @@ export { usePanelStore } from './store' // Toolbar export { useToolbarStore } from './toolbar' export type { ChatContext, PanelState, PanelTab } from './types' -export type { Variable, VariablesStore, VariableType } from './variables' -// Variables -export { useVariablesStore } from './variables' diff --git a/apps/sim/stores/panel/variables/index.ts b/apps/sim/stores/panel/variables/index.ts deleted file mode 100644 index 6a34a434565..00000000000 --- a/apps/sim/stores/panel/variables/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useVariablesStore } from './store' -export type { Variable, VariablesStore, VariableType } from './types' diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts deleted file mode 100644 index dbd8145ba55..00000000000 --- a/apps/sim/stores/panel/variables/store.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { createLogger } from '@sim/logger' -import JSON5 from 'json5' -import { create } from 'zustand' -import { devtools } from 'zustand/middleware' -import { normalizeName } from '@/executor/constants' -import { useOperationQueueStore } from '@/stores/operation-queue/store' -import type { Variable, VariablesStore } from '@/stores/panel/variables/types' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -const logger = createLogger('VariablesStore') - -function validateVariable(variable: Variable): string | undefined { - try { - switch (variable.type) { - case 'number': - if (Number.isNaN(Number(variable.value))) { - return 'Not a valid number' - } - break - case 'boolean': - if (!/^(true|false)$/i.test(String(variable.value).trim())) { - return 'Expected "true" or "false"' - } - break - case 'object': - try { - const valueToEvaluate = String(variable.value).trim() - - if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) { - return 'Not a valid object format' - } - - const parsed = JSON5.parse(valueToEvaluate) - - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return 'Not a valid object' - } - - return undefined - } catch (e) { - logger.error('Object parsing error:', e) - return 'Invalid object syntax' - } - case 'array': - try { - const parsed = JSON5.parse(String(variable.value)) - if (!Array.isArray(parsed)) { - return 'Not a valid array' - } - } catch { - return 'Invalid array syntax' - } - break - } - return undefined - } catch (e) { - return e instanceof Error ? e.message : 'Invalid format' - } -} - -function migrateStringToPlain(variable: Variable): Variable { - if (variable.type !== 'string') { - return variable - } - - const updated = { - ...variable, - type: 'plain' as const, - } - - return updated -} - -export const useVariablesStore = create()( - devtools((set, get) => ({ - variables: {}, - isLoading: false, - error: null, - isEditing: null, - - addVariable: (variable, providedId?: string) => { - const id = providedId || crypto.randomUUID() - - const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId) - - if (!variable.name || /^variable\d+$/.test(variable.name)) { - const existingNumbers = workflowVariables - .map((v) => { - const match = v.name.match(/^variable(\d+)$/) - return match ? Number.parseInt(match[1]) : 0 - }) - .filter((n) => !Number.isNaN(n)) - - const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 - - variable.name = `variable${nextNumber}` - } - - let uniqueName = variable.name - let nameIndex = 1 - - while (workflowVariables.some((v) => v.name === uniqueName)) { - uniqueName = `${variable.name} (${nameIndex})` - nameIndex++ - } - - if (variable.type === 'string') { - variable.type = 'plain' - } - - const newVariable: Variable = { - id, - workflowId: variable.workflowId, - name: uniqueName, - type: variable.type, - value: variable.value || '', - validationError: undefined, - } - - const validationError = validateVariable(newVariable) - if (validationError) { - newVariable.validationError = validationError - } - - set((state) => ({ - variables: { - ...state.variables, - [id]: newVariable, - }, - })) - - return id - }, - - updateVariable: (id, update) => { - set((state) => { - if (!state.variables[id]) return state - - if (update.name !== undefined) { - const oldVariable = state.variables[id] - const oldVariableName = oldVariable.name - const newName = update.name.trim() - - if (!newName) { - update = { ...update } - update.name = undefined - } else if (newName !== oldVariableName) { - const subBlockStore = useSubBlockStore.getState() - const targetWorkflowId = oldVariable.workflowId - - if (targetWorkflowId) { - const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {} - const updatedWorkflowValues = { ...workflowValues } - const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> = - [] - - const oldVarName = normalizeName(oldVariableName) - const newVarName = normalizeName(newName) - const regex = new RegExp(``, 'gi') - - const updateReferences = (value: any, pattern: RegExp, replacement: string): any => { - if (typeof value === 'string') { - return pattern.test(value) ? value.replace(pattern, replacement) : value - } - - if (Array.isArray(value)) { - return value.map((item) => updateReferences(item, pattern, replacement)) - } - - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateReferences(result[key], pattern, replacement) - } - return result - } - - return value - } - - Object.entries(workflowValues).forEach(([blockId, blockValues]) => { - Object.entries(blockValues as Record).forEach( - ([subBlockId, value]) => { - const updatedValue = updateReferences(value, regex, ``) - - if (JSON.stringify(updatedValue) !== JSON.stringify(value)) { - if (!updatedWorkflowValues[blockId]) { - updatedWorkflowValues[blockId] = { ...workflowValues[blockId] } - } - updatedWorkflowValues[blockId][subBlockId] = updatedValue - changedSubBlocks.push({ blockId, subBlockId, value: updatedValue }) - } - } - ) - }) - - // Update local state - useSubBlockStore.setState({ - workflowValues: { - ...subBlockStore.workflowValues, - [targetWorkflowId]: updatedWorkflowValues, - }, - }) - - // Queue operations for persistence via socket - const operationQueue = useOperationQueueStore.getState() - - for (const { blockId, subBlockId, value } of changedSubBlocks) { - operationQueue.addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'subblock-update', - target: 'subblock', - payload: { blockId, subblockId: subBlockId, value }, - }, - workflowId: targetWorkflowId, - userId: 'system', - }) - } - } - } - } - - if (update.type === 'string') { - update = { ...update, type: 'plain' } - } - - const updatedVariable: Variable = { - ...state.variables[id], - ...update, - validationError: undefined, - } - - if (update.type || update.value !== undefined) { - updatedVariable.validationError = validateVariable(updatedVariable) - } - - const updated = { - ...state.variables, - [id]: updatedVariable, - } - - return { variables: updated } - }) - }, - - deleteVariable: (id) => { - set((state) => { - if (!state.variables[id]) return state - - const workflowId = state.variables[id].workflowId - const { [id]: _, ...rest } = state.variables - - return { variables: rest } - }) - }, - - getVariablesByWorkflowId: (workflowId) => { - return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId) - }, - })) -) diff --git a/apps/sim/stores/panel/variables/types.ts b/apps/sim/stores/panel/variables/types.ts deleted file mode 100644 index 7cdfb60d430..00000000000 --- a/apps/sim/stores/panel/variables/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Variable types supported in the application - * Note: 'string' is deprecated - use 'plain' for text values instead - */ -export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string' - -/** - * Represents a workflow variable with workflow-specific naming - * Variable names must be unique within each workflow - */ -export interface Variable { - id: string - workflowId: string - name: string // Must be unique per workflow - type: VariableType - value: unknown - validationError?: string // Tracks format validation errors -} - -export interface VariablesStore { - variables: Record - isLoading: boolean - error: string | null - isEditing: string | null - - /** - * Adds a new variable with automatic name uniqueness validation - * If a variable with the same name exists, it will be suffixed with a number - * Optionally accepts a predetermined ID for collaborative operations - */ - addVariable: (variable: Omit, providedId?: string) => string - - /** - * Updates a variable, ensuring name remains unique within the workflow - * If an updated name conflicts with existing ones, a numbered suffix is added - */ - updateVariable: (id: string, update: Partial>) => void - - deleteVariable: (id: string) => void - - /** - * Returns all variables for a specific workflow - */ - getVariablesByWorkflowId: (workflowId: string) => Variable[] -} diff --git a/apps/sim/stores/variables/index.ts b/apps/sim/stores/variables/index.ts new file mode 100644 index 00000000000..d64ccb216a5 --- /dev/null +++ b/apps/sim/stores/variables/index.ts @@ -0,0 +1,11 @@ +export { + getDefaultVariablesDimensions, + getVariablesPosition, + MAX_VARIABLES_HEIGHT, + MAX_VARIABLES_WIDTH, + MIN_VARIABLES_HEIGHT, + MIN_VARIABLES_WIDTH, + useVariablesModalStore, +} from './modal' +export { useVariablesStore } from './store' +export type { Variable, VariablesStore, VariableType } from './types' diff --git a/apps/sim/stores/variables/modal.ts b/apps/sim/stores/variables/modal.ts new file mode 100644 index 00000000000..be8ff2915b7 --- /dev/null +++ b/apps/sim/stores/variables/modal.ts @@ -0,0 +1,145 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import type { + VariablesDimensions, + VariablesModalStore, + VariablesPosition, +} from '@/stores/variables/types' + +/** + * Floating variables modal default dimensions. + * Slightly larger than the chat modal for more comfortable editing. + */ +const DEFAULT_WIDTH = 320 +const DEFAULT_HEIGHT = 320 + +/** + * Minimum and maximum modal dimensions. + */ +export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH +export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT +export const MAX_VARIABLES_WIDTH = 500 +export const MAX_VARIABLES_HEIGHT = 600 + +/** Inset gap between the viewport edge and the content window */ +const CONTENT_WINDOW_GAP = 8 + +/** + * Compute a center-biased default position, factoring in current layout chrome + * (sidebar, right panel, terminal) and content window inset. + */ +const calculateDefaultPosition = (): VariablesPosition => { + if (typeof window === 'undefined') { + return { x: 100, y: 100 } + } + + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth + const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight + const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2 + const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2 + return { x, y } +} + +/** + * Constrain a position to the visible canvas, considering layout chrome. + */ +const constrainPosition = ( + position: VariablesPosition, + width: number = DEFAULT_WIDTH, + height: number = DEFAULT_HEIGHT +): VariablesPosition => { + if (typeof window === 'undefined') return position + + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + const minX = sidebarWidth + const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width + const minY = CONTENT_WINDOW_GAP + const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height + + return { + x: Math.max(minX, Math.min(maxX, position.x)), + y: Math.max(minY, Math.min(maxY, position.y)), + } +} + +/** + * Return a valid, constrained position. If the stored one is off-bounds due to + * layout changes, prefer a fresh default center position. + */ +export const getVariablesPosition = ( + stored: VariablesPosition | null, + width: number = DEFAULT_WIDTH, + height: number = DEFAULT_HEIGHT +): VariablesPosition => { + if (!stored) return calculateDefaultPosition() + const constrained = constrainPosition(stored, width, height) + const deltaX = Math.abs(constrained.x - stored.x) + const deltaY = Math.abs(constrained.y - stored.y) + if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition() + return constrained +} + +/** + * UI-only store for the floating variables modal. + * Variable data lives in the variables data store (`@/stores/variables/store`). + */ +export const useVariablesModalStore = create()( + devtools( + persist( + (set) => ({ + isOpen: false, + position: null, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + + setIsOpen: (open) => set({ isOpen: open }), + setPosition: (position) => set({ position }), + setDimensions: (dimensions) => + set({ + width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)), + height: Math.max( + MIN_VARIABLES_HEIGHT, + Math.min(MAX_VARIABLES_HEIGHT, dimensions.height) + ), + }), + resetPosition: () => set({ position: null }), + }), + { + name: 'variables-modal-store', + partialize: (state) => ({ + position: state.position, + width: state.width, + height: state.height, + }), + } + ), + { name: 'variables-modal-store' } + ) +) + +/** + * Get default floating variables modal dimensions. + */ +export const getDefaultVariablesDimensions = (): VariablesDimensions => ({ + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, +}) diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index 47a9039ae18..0980f2ae6b3 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -1,145 +1,248 @@ +import { createLogger } from '@sim/logger' +import JSON5 from 'json5' import { create } from 'zustand' -import { devtools, persist } from 'zustand/middleware' -import type { - VariablesDimensions, - VariablesModalStore, - VariablesPosition, -} from '@/stores/variables/types' - -/** - * Floating variables modal default dimensions. - * Slightly larger than the chat modal for more comfortable editing. - */ -const DEFAULT_WIDTH = 320 -const DEFAULT_HEIGHT = 320 - -/** - * Minimum and maximum modal dimensions. - */ -export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH -export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT -export const MAX_VARIABLES_WIDTH = 500 -export const MAX_VARIABLES_HEIGHT = 600 - -/** Inset gap between the viewport edge and the content window */ -const CONTENT_WINDOW_GAP = 8 - -/** - * Compute a center-biased default position, factoring in current layout chrome - * (sidebar, right panel, terminal) and content window inset. - */ -const calculateDefaultPosition = (): VariablesPosition => { - if (typeof window === 'undefined') { - return { x: 100, y: 100 } - } +import { devtools } from 'zustand/middleware' +import { normalizeName } from '@/executor/constants' +import { useOperationQueueStore } from '@/stores/operation-queue/store' +import type { Variable, VariablesStore } from '@/stores/variables/types' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' - const sidebarWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' - ) - const panelWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' - ) - const terminalHeight = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' - ) - - const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth - const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight - const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2 - const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2 - return { x, y } -} +const logger = createLogger('VariablesStore') + +function validateVariable(variable: Variable): string | undefined { + try { + switch (variable.type) { + case 'number': + if (Number.isNaN(Number(variable.value))) { + return 'Not a valid number' + } + break + case 'boolean': + if (!/^(true|false)$/i.test(String(variable.value).trim())) { + return 'Expected "true" or "false"' + } + break + case 'object': + try { + const valueToEvaluate = String(variable.value).trim() + + if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) { + return 'Not a valid object format' + } + + const parsed = JSON5.parse(valueToEvaluate) + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return 'Not a valid object' + } -/** - * Constrain a position to the visible canvas, considering layout chrome. - */ -const constrainPosition = ( - position: VariablesPosition, - width: number = DEFAULT_WIDTH, - height: number = DEFAULT_HEIGHT -): VariablesPosition => { - if (typeof window === 'undefined') return position - - const sidebarWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' - ) - const panelWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' - ) - const terminalHeight = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' - ) - - const minX = sidebarWidth - const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width - const minY = CONTENT_WINDOW_GAP - const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height - - return { - x: Math.max(minX, Math.min(maxX, position.x)), - y: Math.max(minY, Math.min(maxY, position.y)), + return undefined + } catch (e) { + logger.error('Object parsing error:', e) + return 'Invalid object syntax' + } + case 'array': + try { + const parsed = JSON5.parse(String(variable.value)) + if (!Array.isArray(parsed)) { + return 'Not a valid array' + } + } catch { + return 'Invalid array syntax' + } + break + } + return undefined + } catch (e) { + return e instanceof Error ? e.message : 'Invalid format' } } -/** - * Return a valid, constrained position. If the stored one is off-bounds due to - * layout changes, prefer a fresh default center position. - */ -export const getVariablesPosition = ( - stored: VariablesPosition | null, - width: number = DEFAULT_WIDTH, - height: number = DEFAULT_HEIGHT -): VariablesPosition => { - if (!stored) return calculateDefaultPosition() - const constrained = constrainPosition(stored, width, height) - const deltaX = Math.abs(constrained.x - stored.x) - const deltaY = Math.abs(constrained.y - stored.y) - if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition() - return constrained -} +export const useVariablesStore = create()( + devtools((set, get) => ({ + variables: {}, + isLoading: false, + error: null, + isEditing: null, + + addVariable: (variable, providedId?: string) => { + const id = providedId || crypto.randomUUID() + + const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId) -/** - * UI-only store for the floating variables modal. - * Variable data lives in the panel variables store (`@/stores/panel/variables`). - */ -export const useVariablesStore = create()( - devtools( - persist( - (set) => ({ - isOpen: false, - position: null, - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - - setIsOpen: (open) => set({ isOpen: open }), - setPosition: (position) => set({ position }), - setDimensions: (dimensions) => - set({ - width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)), - height: Math.max( - MIN_VARIABLES_HEIGHT, - Math.min(MAX_VARIABLES_HEIGHT, dimensions.height) - ), - }), - resetPosition: () => set({ position: null }), - }), - { - name: 'variables-modal-store', - partialize: (state) => ({ - position: state.position, - width: state.width, - height: state.height, - }), + if (!variable.name || /^variable\d+$/.test(variable.name)) { + const existingNumbers = workflowVariables + .map((v) => { + const match = v.name.match(/^variable(\d+)$/) + return match ? Number.parseInt(match[1]) : 0 + }) + .filter((n) => !Number.isNaN(n)) + + const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1 + + variable.name = `variable${nextNumber}` } - ), - { name: 'variables-modal-store' } - ) -) -/** - * Get default floating variables modal dimensions. - */ -export const getDefaultVariablesDimensions = (): VariablesDimensions => ({ - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, -}) + let uniqueName = variable.name + let nameIndex = 1 + + while (workflowVariables.some((v) => v.name === uniqueName)) { + uniqueName = `${variable.name} (${nameIndex})` + nameIndex++ + } + + if (variable.type === 'string') { + variable.type = 'plain' + } + + const newVariable: Variable = { + id, + workflowId: variable.workflowId, + name: uniqueName, + type: variable.type, + value: variable.value || '', + validationError: undefined, + } + + const validationError = validateVariable(newVariable) + if (validationError) { + newVariable.validationError = validationError + } + + set((state) => ({ + variables: { + ...state.variables, + [id]: newVariable, + }, + })) + + return id + }, + + updateVariable: (id, update) => { + set((state) => { + if (!state.variables[id]) return state + + if (update.name !== undefined) { + const oldVariable = state.variables[id] + const oldVariableName = oldVariable.name + const newName = update.name.trim() + + if (!newName) { + update = { ...update } + update.name = undefined + } else if (newName !== oldVariableName) { + const subBlockStore = useSubBlockStore.getState() + const targetWorkflowId = oldVariable.workflowId + + if (targetWorkflowId) { + const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {} + const updatedWorkflowValues = { ...workflowValues } + const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> = + [] + + const oldVarName = normalizeName(oldVariableName) + const newVarName = normalizeName(newName) + const regex = new RegExp(``, 'gi') + + const updateReferences = (value: any, pattern: RegExp, replacement: string): any => { + if (typeof value === 'string') { + return pattern.test(value) ? value.replace(pattern, replacement) : value + } + + if (Array.isArray(value)) { + return value.map((item) => updateReferences(item, pattern, replacement)) + } + + if (value !== null && typeof value === 'object') { + const result = { ...value } + for (const key in result) { + result[key] = updateReferences(result[key], pattern, replacement) + } + return result + } + + return value + } + + Object.entries(workflowValues).forEach(([blockId, blockValues]) => { + Object.entries(blockValues as Record).forEach( + ([subBlockId, value]) => { + const updatedValue = updateReferences(value, regex, ``) + + if (JSON.stringify(updatedValue) !== JSON.stringify(value)) { + if (!updatedWorkflowValues[blockId]) { + updatedWorkflowValues[blockId] = { ...workflowValues[blockId] } + } + updatedWorkflowValues[blockId][subBlockId] = updatedValue + changedSubBlocks.push({ blockId, subBlockId, value: updatedValue }) + } + } + ) + }) + + // Update local state + useSubBlockStore.setState({ + workflowValues: { + ...subBlockStore.workflowValues, + [targetWorkflowId]: updatedWorkflowValues, + }, + }) + + // Queue operations for persistence via socket + const operationQueue = useOperationQueueStore.getState() + + for (const { blockId, subBlockId, value } of changedSubBlocks) { + operationQueue.addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'subblock-update', + target: 'subblock', + payload: { blockId, subblockId: subBlockId, value }, + }, + workflowId: targetWorkflowId, + userId: 'system', + }) + } + } + } + } + + if (update.type === 'string') { + update = { ...update, type: 'plain' } + } + + const updatedVariable: Variable = { + ...state.variables[id], + ...update, + validationError: undefined, + } + + if (update.type || update.value !== undefined) { + updatedVariable.validationError = validateVariable(updatedVariable) + } + + const updated = { + ...state.variables, + [id]: updatedVariable, + } + + return { variables: updated } + }) + }, + + deleteVariable: (id) => { + set((state) => { + if (!state.variables[id]) return state + + const { [id]: _, ...rest } = state.variables + + return { variables: rest } + }) + }, + + getVariablesByWorkflowId: (workflowId) => { + return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId) + }, + })) +) diff --git a/apps/sim/stores/variables/types.ts b/apps/sim/stores/variables/types.ts index 89ed0fd62fb..a3b43e5be8b 100644 --- a/apps/sim/stores/variables/types.ts +++ b/apps/sim/stores/variables/types.ts @@ -1,3 +1,49 @@ +/** + * Variable types supported in the application + * Note: 'string' is deprecated - use 'plain' for text values instead + */ +export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string' + +/** + * Represents a workflow variable with workflow-specific naming + * Variable names must be unique within each workflow + */ +export interface Variable { + id: string + workflowId: string + name: string // Must be unique per workflow + type: VariableType + value: unknown + validationError?: string // Tracks format validation errors +} + +export interface VariablesStore { + variables: Record + isLoading: boolean + error: string | null + isEditing: string | null + + /** + * Adds a new variable with automatic name uniqueness validation + * If a variable with the same name exists, it will be suffixed with a number + * Optionally accepts a predetermined ID for collaborative operations + */ + addVariable: (variable: Omit, providedId?: string) => string + + /** + * Updates a variable, ensuring name remains unique within the workflow + * If an updated name conflicts with existing ones, a numbered suffix is added + */ + updateVariable: (id: string, update: Partial>) => void + + deleteVariable: (id: string) => void + + /** + * Returns all variables for a specific workflow + */ + getVariablesByWorkflowId: (workflowId: string) => Variable[] +} + /** * 2D position used by the floating variables modal. */ @@ -16,7 +62,7 @@ export interface VariablesDimensions { /** * UI-only store interface for the floating variables modal. - * Variable data lives in the panel variables store (`@/stores/panel/variables`). + * Variable data lives in the variables data store (`@/stores/variables/store`). */ export interface VariablesModalStore { isOpen: boolean diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 59be5bf8b3d..48a5b4a7a7c 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -6,8 +6,8 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments' import { deploymentKeys } from '@/hooks/queries/deployments' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' -import { useVariablesStore } from '@/stores/panel/variables/store' -import type { Variable } from '@/stores/panel/variables/types' +import { useVariablesStore } from '@/stores/variables/store' +import type { Variable } from '@/stores/variables/types' import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils' From a0796f088b1f0f1db6399a9974e68b3f47db7270 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 3 Apr 2026 18:44:14 -0700 Subject: [PATCH 05/10] improvement(mothership): workflow edits via sockets (#3927) * improvement(mothership): workflow edits via sockets * make embedded view join room * fix cursor positioning bug --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 17 +- .../workspace/providers/socket-provider.tsx | 16 +- apps/sim/hooks/use-collaborative-workflow.ts | 153 ++++++++++-------- .../server/workflow/edit-workflow/index.ts | 13 ++ 4 files changed, 126 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index a94d4aa9607..0a8c196c60c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -265,7 +265,7 @@ const WorkflowContent = React.memo( const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, { embedded, }) - const { emitCursorUpdate } = useSocket() + const { emitCursorUpdate, joinWorkflow, leaveWorkflow } = useSocket() useDynamicHandleRefresh() const workspaceId = propWorkspaceId || (params.workspaceId as string) @@ -273,6 +273,14 @@ const WorkflowContent = React.memo( const addNotification = useNotificationStore((state) => state.addNotification) + useEffect(() => { + if (!embedded || !workflowIdParam) return + joinWorkflow(workflowIdParam) + return () => { + leaveWorkflow() + } + }, [embedded, workflowIdParam, joinWorkflow, leaveWorkflow]) + useOAuthReturnForWorkflow(workflowIdParam) const { @@ -2144,12 +2152,9 @@ const WorkflowContent = React.memo( const handleCanvasPointerMove = useCallback( (event: React.PointerEvent) => { - const target = event.currentTarget as HTMLElement - const bounds = target.getBoundingClientRect() - const position = screenToFlowPosition({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, + x: event.clientX, + y: event.clientY, }) emitCursorUpdate(position) diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 47c00157d0f..91a415df132 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -90,6 +90,7 @@ interface SocketContextType { onSelectionUpdate: (handler: (data: any) => void) => void onWorkflowDeleted: (handler: (data: any) => void) => void onWorkflowReverted: (handler: (data: any) => void) => void + onWorkflowUpdated: (handler: (data: any) => void) => void onOperationConfirmed: (handler: (data: any) => void) => void onOperationFailed: (handler: (data: any) => void) => void } @@ -118,6 +119,7 @@ const SocketContext = createContext({ onSelectionUpdate: () => {}, onWorkflowDeleted: () => {}, onWorkflowReverted: () => {}, + onWorkflowUpdated: () => {}, onOperationConfirmed: () => {}, onOperationFailed: () => {}, }) @@ -155,6 +157,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { selectionUpdate?: (data: any) => void workflowDeleted?: (data: any) => void workflowReverted?: (data: any) => void + workflowUpdated?: (data: any) => void operationConfirmed?: (data: any) => void operationFailed?: (data: any) => void }>({}) @@ -334,7 +337,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => { isRejoiningRef.current = false // Ignore stale success responses from previous navigation - if (workflowId !== urlWorkflowIdRef.current) { + if (urlWorkflowIdRef.current && workflowId !== urlWorkflowIdRef.current) { logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`) return } @@ -382,6 +385,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowReverted?.(data) }) + socketInstance.on('workflow-updated', (data) => { + logger.info(`Workflow ${data.workflowId} has been updated externally`) + eventHandlers.current.workflowUpdated?.(data) + }) + const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => { const [ { useOperationQueueStore }, @@ -803,6 +811,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowReverted = handler }, []) + const onWorkflowUpdated = useCallback((handler: (data: any) => void) => { + eventHandlers.current.workflowUpdated = handler + }, []) + const onOperationConfirmed = useCallback((handler: (data: any) => void) => { eventHandlers.current.operationConfirmed = handler }, []) @@ -836,6 +848,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onSelectionUpdate, onWorkflowDeleted, onWorkflowReverted, + onWorkflowUpdated, onOperationConfirmed, onOperationFailed, }), @@ -863,6 +876,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onSelectionUpdate, onWorkflowDeleted, onWorkflowReverted, + onWorkflowUpdated, onOperationConfirmed, onOperationFailed, ] diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index b878b89a274..60c36eeb645 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -123,6 +123,7 @@ export function useCollaborativeWorkflow() { onVariableUpdate, onWorkflowDeleted, onWorkflowReverted, + onWorkflowUpdated, onOperationConfirmed, onOperationFailed, } = useSocket() @@ -537,81 +538,99 @@ export function useCollaborativeWorkflow() { } } + const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise => { + const response = await fetch(`/api/workflows/${workflowId}`) + if (!response.ok) { + logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`) + return false + } + + const responseData = await response.json() + const workflowData = responseData.data + + if (!workflowData?.state) { + logger.error(`No state found in workflow data after ${reason}`, { workflowData }) + return false + } + + isApplyingRemoteChange.current = true + try { + useWorkflowStore.getState().replaceWorkflowState({ + blocks: workflowData.state.blocks || {}, + edges: workflowData.state.edges || [], + loops: workflowData.state.loops || {}, + parallels: workflowData.state.parallels || {}, + lastSaved: workflowData.state.lastSaved || Date.now(), + }) + + const subblockValues: Record> = {} + Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => { + const blockState = block as any + subblockValues[blockId] = {} + Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) + }) + + useSubBlockStore.setState((state: any) => ({ + workflowValues: { + ...state.workflowValues, + [workflowId]: subblockValues, + }, + })) + + const graph = { + blocksById: workflowData.state.blocks || {}, + edgesById: Object.fromEntries( + (workflowData.state.edges || []).map((e: any) => [e.id, e]) + ), + } + + const undoRedoStore = useUndoRedoStore.getState() + const stackKeys = Object.keys(undoRedoStore.stacks) + stackKeys.forEach((key) => { + const [wfId, userId] = key.split(':') + if (wfId === workflowId) { + undoRedoStore.pruneInvalidEntries(wfId, userId, graph) + } + }) + + logger.info(`Successfully reloaded workflow state after ${reason}`, { workflowId }) + return true + } finally { + isApplyingRemoteChange.current = false + } + } + const handleWorkflowReverted = async (data: any) => { const { workflowId } = data logger.info(`Workflow ${workflowId} has been reverted to deployed state`) - // If the reverted workflow is the currently active one, reload the workflow state - if (activeWorkflowId === workflowId) { - logger.info(`Currently active workflow ${workflowId} was reverted, reloading state`) - - try { - // Fetch the updated workflow state from the server (which loads from normalized tables) - const response = await fetch(`/api/workflows/${workflowId}`) - if (response.ok) { - const responseData = await response.json() - const workflowData = responseData.data - - if (workflowData?.state) { - // Update the workflow store with the reverted state - isApplyingRemoteChange.current = true - try { - // Update the main workflow state using the API response - useWorkflowStore.getState().replaceWorkflowState({ - blocks: workflowData.state.blocks || {}, - edges: workflowData.state.edges || [], - loops: workflowData.state.loops || {}, - parallels: workflowData.state.parallels || {}, - lastSaved: workflowData.state.lastSaved || Date.now(), - }) + if (activeWorkflowId !== workflowId) return - // Update subblock store with reverted values - const subblockValues: Record> = {} - Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => { - const blockState = block as any - subblockValues[blockId] = {} - Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value - }) - }) + try { + await reloadWorkflowFromApi(workflowId, 'revert') + } catch (error) { + logger.error('Error reloading workflow state after revert:', error) + } + } - // Update subblock store for this workflow - useSubBlockStore.setState((state: any) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: subblockValues, - }, - })) + const handleWorkflowUpdated = async (data: any) => { + const { workflowId } = data + logger.info(`Workflow ${workflowId} has been updated externally`) - logger.info(`Successfully loaded reverted workflow state for ${workflowId}`) + if (activeWorkflowId !== workflowId) return - const graph = { - blocksById: workflowData.state.blocks || {}, - edgesById: Object.fromEntries( - (workflowData.state.edges || []).map((e: any) => [e.id, e]) - ), - } + const { hasActiveDiff } = useWorkflowDiffStore.getState() + if (hasActiveDiff) { + logger.info('Skipping workflow-updated: active diff in progress', { workflowId }) + return + } - const undoRedoStore = useUndoRedoStore.getState() - const stackKeys = Object.keys(undoRedoStore.stacks) - stackKeys.forEach((key) => { - const [wfId, userId] = key.split(':') - if (wfId === workflowId) { - undoRedoStore.pruneInvalidEntries(wfId, userId, graph) - } - }) - } finally { - isApplyingRemoteChange.current = false - } - } else { - logger.error('No state found in workflow data after revert', { workflowData }) - } - } else { - logger.error(`Failed to fetch workflow data after revert: ${response.statusText}`) - } - } catch (error) { - logger.error('Error reloading workflow state after revert:', error) - } + try { + await reloadWorkflowFromApi(workflowId, 'external update') + } catch (error) { + logger.error('Error reloading workflow state after external update:', error) } } @@ -633,6 +652,7 @@ export function useCollaborativeWorkflow() { onVariableUpdate(handleVariableUpdate) onWorkflowDeleted(handleWorkflowDeleted) onWorkflowReverted(handleWorkflowReverted) + onWorkflowUpdated(handleWorkflowUpdated) onOperationConfirmed(handleOperationConfirmed) onOperationFailed(handleOperationFailed) }, [ @@ -641,6 +661,7 @@ export function useCollaborativeWorkflow() { onVariableUpdate, onWorkflowDeleted, onWorkflowReverted, + onWorkflowUpdated, onOperationConfirmed, onOperationFailed, activeWorkflowId, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index eb0a0f23ed6..7be066d2989 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -7,6 +7,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { env } from '@/lib/core/config/env' import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout' import { DEFAULT_HORIZONTAL_SPACING, @@ -287,6 +288,18 @@ export const editWorkflowServerTool: BaseServerTool logger.info('Workflow state persisted to database', { workflowId }) + const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + fetch(`${socketUrl}/api/workflow-updated`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId }), + }).catch((error) => { + logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) + }) + const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined return { From 8ce0299400191f06feae142b127c5d6e28c9092a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 3 Apr 2026 18:58:42 -0700 Subject: [PATCH 06/10] fix(ui) Fix oauth redirect on connector modal (#3926) * Fix oauth redirect on connector modal * Fix lint --------- Co-authored-by: Theodore Li --- .../[workspaceId]/components/oauth-modal.tsx | 15 ++++--- .../[workspaceId]/knowledge/[id]/base.tsx | 41 ++++++++++++++++--- .../add-connector-modal.tsx | 21 ++++++++-- apps/sim/hooks/use-oauth-return.ts | 7 +++- apps/sim/lib/credentials/client-state.ts | 3 ++ 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx index 5f4e89edd09..7720c84fa3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx @@ -16,7 +16,7 @@ import { } from '@/components/emcn' import { client, useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' -import { writeOAuthReturnContext } from '@/lib/credentials/client-state' +import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & { workspaceId: string credentialCount: number } & ( - | { workflowId: string; knowledgeBaseId?: never } - | { workflowId?: never; knowledgeBaseId: string } + | { workflowId: string; knowledgeBaseId?: never; connectorType?: never } + | { workflowId?: never; knowledgeBaseId: string; connectorType?: string } ) interface OAuthModalReauthorizeProps extends OAuthModalBaseProps { @@ -81,6 +81,7 @@ export function OAuthModal(props: OAuthModalProps) { const workspaceId = isConnect ? props.workspaceId : '' const workflowId = isConnect ? props.workflowId : undefined const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined + const connectorType = isConnect ? props.connectorType : undefined const toolName = !isConnect ? props.toolName : '' const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES @@ -172,7 +173,7 @@ export function OAuthModal(props: OAuthModalProps) { } const returnContext: OAuthReturnContext = knowledgeBaseId - ? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId } + ? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType } : { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! } writeOAuthReturnContext(returnContext) @@ -205,7 +206,11 @@ export function OAuthModal(props: OAuthModalProps) { return } - await client.oauth2.link({ providerId, callbackURL: window.location.href }) + const callbackURL = new URL(window.location.href) + if (connectorType) { + callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType) + } + await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() }) handleClose() } catch (err) { logger.error('Failed to initiate OAuth connection', { error: err }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index eb5823f9100..e81701bac4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { format } from 'date-fns' import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Badge, @@ -25,6 +25,7 @@ import { import { Database, DatabaseX } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' import { cn } from '@/lib/core/utils/cn' +import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' @@ -192,6 +193,10 @@ export function KnowledgeBase({ }: KnowledgeBaseProps) { const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) + const router = useRouter() + const searchParams = useSearchParams() + const pathname = usePathname() + const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM) const posthog = usePostHog() useEffect(() => { @@ -278,7 +283,29 @@ export function KnowledgeBase({ const [contextMenuDocument, setContextMenuDocument] = useState(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) - const [showAddConnectorModal, setShowAddConnectorModal] = useState(false) + const showAddConnectorModal = addConnectorParam != null + const searchParamsRef = useRef(searchParams) + searchParamsRef.current = searchParams + const updateAddConnectorParam = useCallback( + (value: string | null) => { + const current = searchParamsRef.current + const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM) + if (value === currentValue || (value === null && currentValue === null)) return + const next = new URLSearchParams(current.toString()) + if (value === null) { + next.delete(ADD_CONNECTOR_SEARCH_PARAM) + } else { + next.set(ADD_CONNECTOR_SEARCH_PARAM, value) + } + const qs = next.toString() + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) + }, + [pathname, router] + ) + const setShowAddConnectorModal = useCallback( + (open: boolean) => updateAddConnectorParam(open ? '' : null), + [updateAddConnectorParam] + ) const { isOpen: isContextMenuOpen, @@ -340,8 +367,6 @@ export function KnowledgeBase({ prevHadSyncingRef.current = hasSyncingConnectors }, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments]) - const router = useRouter() - const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' const error = knowledgeBaseError || documentsError @@ -1254,7 +1279,13 @@ export function KnowledgeBase({ /> {showAddConnectorModal && ( - + )} {documentToRename && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 223091360bc..ce218132643 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -44,14 +44,22 @@ const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY) interface AddConnectorModalProps { open: boolean onOpenChange: (open: boolean) => void + onConnectorTypeChange?: (connectorType: string | null) => void knowledgeBaseId: string + initialConnectorType?: string | null } type Step = 'select-type' | 'configure' -export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) { - const [step, setStep] = useState('select-type') - const [selectedType, setSelectedType] = useState(null) +export function AddConnectorModal({ + open, + onOpenChange, + onConnectorTypeChange, + knowledgeBaseId, + initialConnectorType, +}: AddConnectorModalProps) { + const [step, setStep] = useState(() => (initialConnectorType ? 'configure' : 'select-type')) + const [selectedType, setSelectedType] = useState(initialConnectorType ?? null) const [sourceConfig, setSourceConfig] = useState>({}) const [syncInterval, setSyncInterval] = useState(1440) const [selectedCredentialId, setSelectedCredentialId] = useState(null) @@ -151,6 +159,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo setError(null) setSearchTerm('') setStep('configure') + onConnectorTypeChange?.(type) } const handleFieldChange = useCallback( @@ -286,7 +295,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo @@ -565,6 +577,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo workspaceId={workspaceId} knowledgeBaseId={knowledgeBaseId} credentialCount={credentials.length} + connectorType={selectedType ?? undefined} /> )} diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index ad64918b877..bc260e901e5 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { toast } from '@/components/emcn' import { + ADD_CONNECTOR_SEARCH_PARAM, consumeOAuthReturnContext, type OAuthReturnContext, readOAuthReturnContext, @@ -98,7 +99,11 @@ export function useOAuthReturnRouter() { try { sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) } catch {} - router.replace(`/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`) + const kbUrl = `/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}` + const connectorParam = ctx.connectorType + ? `?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(ctx.connectorType)}` + : '' + router.replace(`${kbUrl}${connectorParam}`) return } }, [router, workspaceId]) diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index a7216677c4c..fcaf88fa121 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -91,6 +91,8 @@ export function clearPendingCredentialCreateRequest() { window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY) } +export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const + const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context' export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors' @@ -116,6 +118,7 @@ interface OAuthReturnIntegrations extends OAuthReturnBase { interface OAuthReturnKBConnectors extends OAuthReturnBase { origin: 'kb-connectors' knowledgeBaseId: string + connectorType?: string } export type OAuthReturnContext = From 57e5bac12105d8c67a167c5c4c7a57a9ffcc4a29 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 19:05:10 -0700 Subject: [PATCH 07/10] fix(mcp): resolve userId before JWT generation for agent block auth (#3932) * fix(mcp): resolve userId before JWT generation for agent block auth * test(mcp): add regression test for agent block JWT userId resolution --- apps/sim/tools/index.test.ts | 34 ++++++++++++++++++++++++++++++++++ apps/sim/tools/index.ts | 6 +++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1c7df7a07c6..e3ecf97fae2 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -25,6 +25,7 @@ const { mockGetCustomToolById, mockListCustomTools, mockGetCustomToolByIdOrTitle, + mockGenerateInternalToken, } = vi.hoisted(() => ({ mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, @@ -38,6 +39,7 @@ const { mockGetCustomToolById: vi.fn(), mockListCustomTools: vi.fn(), mockGetCustomToolByIdOrTitle: vi.fn(), + mockGenerateInternalToken: vi.fn(), })) // Mock feature flags @@ -65,6 +67,10 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: (...args: unknown[]) => mockGetBYOKKey(...args), })) +vi.mock('@/lib/auth/internal', () => ({ + generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args), +})) + vi.mock('@/lib/billing/core/usage-log', () => ({})) vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ @@ -1154,6 +1160,34 @@ describe('MCP Tool Execution', () => { expect(result.timing).toBeDefined() }) + it('should embed userId in JWT when executionContext is undefined (agent block path)', async () => { + mockGenerateInternalToken.mockResolvedValue('test-token') + + global.fetch = Object.assign( + vi.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + success: true, + data: { output: { content: [{ type: 'text', text: 'OK' }] } }, + }), + })), + { preconnect: vi.fn() } + ) as typeof fetch + + await executeTool('mcp-123-test_tool', { + query: 'test', + _context: { + workspaceId: 'workspace-456', + workflowId: 'workflow-789', + userId: 'user-abc', + }, + }) + + expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-abc') + }) + describe('Tool request retries', () => { function makeJsonResponse( status: number, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 737dfa47bbd..e48e05baf2d 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1552,11 +1552,13 @@ async function executeMcpTool( const baseUrl = getInternalApiBaseUrl() + const mcpScope = resolveToolScope(params, executionContext) + const headers: Record = { 'Content-Type': 'application/json' } if (typeof window === 'undefined') { try { - const internalToken = await generateInternalToken(executionContext?.userId) + const internalToken = await generateInternalToken(mcpScope.userId) headers.Authorization = `Bearer ${internalToken}` } catch (error) { logger.error(`[${actualRequestId}] Failed to generate internal token:`, error) @@ -1587,8 +1589,6 @@ async function executeMcpTool( ) } - const mcpScope = resolveToolScope(params, executionContext) - if (mcpScope.callChain && mcpScope.callChain.length > 0) { headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain) } From 2e69f85364de8e97d07ec27c71244971c847db5a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 3 Apr 2026 19:11:45 -0700 Subject: [PATCH 08/10] Fix "fix in copilot" button (#3931) * Fix "fix in copilot" button * Auto send message to copilot for fix in copilot --------- Co-authored-by: Theodore Li --- .../w/[workflowId]/components/panel/panel.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index cc36b7cf9eb..4c5ecbbc570 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -411,6 +411,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setHasHydrated(true) }, [setHasHydrated]) + useEffect(() => { + const handler = (e: Event) => { + const message = (e as CustomEvent<{ message: string }>).detail?.message + if (!message) return + setActiveTab('copilot') + copilotSendMessage(message) + } + window.addEventListener('mothership-send-message', handler) + return () => window.removeEventListener('mothership-send-message', handler) + }, [setActiveTab, copilotSendMessage]) + /** * Handles tab click events */ From 3267d8cc24083c7f9f7a9ca1d56f624bf75fe6bd Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 19:19:36 -0700 Subject: [PATCH 09/10] fix(modals): center modals in visible content area accounting for sidebar and panel (#3934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(modals): center modals in visible content area accounting for sidebar and panel * fix(modals): address pr feedback — comment clarity and document panel assumption --- apps/sim/app/_styles/globals.css | 2 +- apps/sim/app/layout.tsx | 7 ++++++- .../sidebar/components/search-modal/search-modal.tsx | 6 +++++- apps/sim/components/emcn/components/modal/modal.tsx | 10 ++++++++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 0c80aef072a..254534f2891 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -10,7 +10,7 @@ * @see stores/constants.ts for the source of truth */ :root { - --sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */ + --sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */ --panel-width: 320px; /* PANEL_WIDTH.DEFAULT */ --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 05fd18a7c0b..5863c4405d3 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -90,6 +90,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } // Sidebar width + var defaultSidebarWidth = '248px'; try { var stored = localStorage.getItem('sidebar-state'); if (stored) { @@ -108,11 +109,15 @@ export default function RootLayout({ children }: { children: React.ReactNode }) document.documentElement.style.setProperty('--sidebar-width', width + 'px'); } else if (width > maxSidebarWidth) { document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px'); + } else { + document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth); } } + } else { + document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth); } } catch (e) { - // Fallback handled by CSS defaults + document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth); } // Panel width and active tab diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 23c34bfdea4..51a9533e79d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -343,7 +343,11 @@ export function SearchModal({ '-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]', open ? 'visible opacity-100' : 'invisible opacity-0' )} - style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }} + style={{ + left: isOnWorkflowPage + ? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)' + : 'calc(var(--sidebar-width) / 2 + 50%)', + }} >
diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index 2932bb2d5c9..3995947dba1 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -40,6 +40,7 @@ import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import * as TabsPrimitive from '@radix-ui/react-tabs' import { X } from 'lucide-react' +import { usePathname } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { Button } from '../button/button' @@ -55,7 +56,7 @@ const ANIMATION_CLASSES = * We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects. */ const CONTENT_ANIMATION_CLASSES = - 'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none' + 'data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none' /** * Root modal component. Manages open state. @@ -145,6 +146,8 @@ const ModalContent = React.forwardRef< ModalContentProps >(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => { const [isInteractionReady, setIsInteractionReady] = React.useState(false) + const pathname = usePathname() + const isWorkflowPage = pathname?.includes('/w/') ?? false React.useEffect(() => { const timer = setTimeout(() => setIsInteractionReady(true), 100) @@ -164,7 +167,10 @@ const ModalContent = React.forwardRef< className )} style={{ - left: '50%', + left: isWorkflowPage + ? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed) + 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)' + : 'calc(var(--sidebar-width) / 2 + 50%)', ...style, }} onEscapeKeyDown={(e) => { From 6d00d6bf2c133b2486c7c6d8873dadfc21f1aac2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Apr 2026 20:06:10 -0700 Subject: [PATCH 10/10] fix(modals): center modals in visible content area and remove open/close animation (#3937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(modals): center modals in visible content area accounting for sidebar and panel * fix(modals): address pr feedback — comment clarity and document panel assumption * fix(modals): remove open/close animation from modal content --- apps/sim/components/emcn/components/modal/modal.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index 3995947dba1..f9afb95b6d6 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -51,13 +51,6 @@ import { Button } from '../button/button' const ANIMATION_CLASSES = 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none' -/** - * Modal content animation classes. - * We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects. - */ -const CONTENT_ANIMATION_CLASSES = - 'data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none' - /** * Root modal component. Manages open state. */ @@ -160,8 +153,6 @@ const ModalContent = React.forwardRef<