diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index cec1c8c0ba4..475fdbf6b29 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -32,6 +32,8 @@ import { isKnownWorkflowTriggerBlock, isWorkflowAnnotationOnlyBlockType, isWorkflowBlockProtected, + normalizeWorkflowEdgeSourceHandle, + normalizeWorkflowEdgeTargetHandle, } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' @@ -57,13 +59,21 @@ function toEdgeHandles(edge: PersistedEdgeRecord) { } interface EdgeAddCandidate { - id?: string + id: string source: string target: string sourceHandle?: string | null targetHandle?: string | null } +function canonicalizeEdgeAddCandidate(edge: EdgeAddCandidate): EdgeAddCandidate { + return { + ...edge, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), + } +} + interface FilterEdgesForPersistResult { safeEdges: T[] droppedCounts: Record @@ -283,8 +293,8 @@ async function insertAutoConnectEdge( workflowId, sourceBlockId: autoConnectEdge.source, targetBlockId: autoConnectEdge.target, - sourceHandle: autoConnectEdge.sourceHandle || null, - targetHandle: autoConnectEdge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle), }) logger.debug( `Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}` @@ -618,6 +628,33 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_DESCRIPTION: { + if (!payload.id || payload.description === undefined) { + throw new Error('Missing required fields for update description operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + data: sql`jsonb_set( + coalesce(${workflowBlocks.data}, '{}'::jsonb), + '{description}', + ${JSON.stringify(payload.description)}::jsonb, + true + )`, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block description: ${payload.id}`) + break + } + case BLOCK_OPERATIONS.TOGGLE_ENABLED: { if (!payload.id) { throw new Error('Missing block ID for toggle enabled operation') @@ -736,6 +773,33 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: { + if (!payload.id || payload.errorEnabled === undefined) { + throw new Error('Missing required fields for update error enabled operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + data: sql`jsonb_set( + coalesce(${workflowBlocks.data}, '{}'::jsonb), + '{errorEnabled}', + ${JSON.stringify(payload.errorEnabled)}::jsonb, + true + )`, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`) + break + } + case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: { if (!payload.id || !payload.canonicalId || !payload.canonicalMode) { throw new Error('Missing required fields for update canonical mode operation') @@ -1024,8 +1088,8 @@ async function handleBlocksOperationTx( // blocksById lookup (a plain `tx.select` from `workflowBlocks`) also // sees the blocks this same batch just inserted — reads observe a // transaction's own prior writes. - const candidates: EdgeAddCandidate[] = (edges as Array>).map( - (e) => ({ + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ id: e.id as string, source: e.source as string, target: e.target as string, @@ -1051,8 +1115,8 @@ async function handleBlocksOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -1509,18 +1573,17 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str throw new Error('Missing required fields for add edge operation') } + const candidate = canonicalizeEdgeAddCandidate({ + id: payload.id, + source: payload.source, + target: payload.target, + sourceHandle: payload.sourceHandle ?? null, + targetHandle: payload.targetHandle ?? null, + }) const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist( tx, workflowId, - [ - { - id: payload.id, - source: payload.source, - target: payload.target, - sourceHandle: payload.sourceHandle ?? null, - targetHandle: payload.targetHandle ?? null, - }, - ] + [candidate] ) if (safeEdges.length === 0) { @@ -1534,13 +1597,14 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } + const [safeEdge] = safeEdges await tx.insert(workflowEdges).values({ - id: payload.id, + id: safeEdge.id, workflowId, - sourceBlockId: payload.source, - targetBlockId: payload.target, - sourceHandle: payload.sourceHandle || null, - targetHandle: payload.targetHandle || null, + sourceBlockId: safeEdge.source, + targetBlockId: safeEdge.target, + sourceHandle: normalizeWorkflowEdgeSourceHandle(safeEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(safeEdge.targetHandle), }) logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) @@ -1755,13 +1819,15 @@ async function handleEdgesOperationTx( logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) - const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => ({ - id: e.id as string, - source: e.source as string, - target: e.target as string, - sourceHandle: (e.sourceHandle as string | null) ?? null, - targetHandle: (e.targetHandle as string | null) ?? null, - })) + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ + id: e.id as string, + source: e.source as string, + target: e.target as string, + sourceHandle: (e.sourceHandle as string | null) ?? null, + targetHandle: (e.targetHandle as string | null) ?? null, + }) + ) const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } = await filterEdgesForPersist(tx, workflowId, candidates) @@ -1784,8 +1850,8 @@ async function handleEdgesOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -2152,16 +2218,34 @@ async function handleWorkflowOperationTx( // Insert all edges from the new state if (edges && edges.length > 0) { - const edgeValues = edges.map((edge: any) => ({ + const canonicalEdges = (edges as Array>).map((edge) => + canonicalizeEdgeAddCandidate({ + id: edge.id as string, + source: edge.source as string, + target: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) ?? null, + targetHandle: (edge.targetHandle as string | null) ?? null, + }) + ) + const uniqueEdges = filterUniqueWorkflowEdges(canonicalEdges, []) + const edgeValues = uniqueEdges.map((edge) => ({ id: edge.id, workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: edge.sourceHandle ?? null, + targetHandle: edge.targetHandle ?? null, })) - await tx.insert(workflowEdges).values(edgeValues) + if (uniqueEdges.length < edges.length) { + logger.info(`Dropped ${edges.length - uniqueEdges.length} duplicate edge(s)`, { + operation: WORKFLOW_OPERATIONS.REPLACE_STATE, + }) + } + + if (edgeValues.length > 0) { + await tx.insert(workflowEdges).values(edgeValues) + } } // Insert all loops from the new state diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index c109259f390..ad4bc8b7ddc 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -263,6 +263,12 @@ describe('checkRolePermission', () => { { operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false }, + { + operation: 'update-description', + adminAllowed: true, + writeAllowed: true, + readAllowed: false, + }, { operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false }, { diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 69892a590a6..f9af2dc5189 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -26,9 +26,11 @@ const WRITE_OPERATIONS: string[] = [ // Block operations BLOCK_OPERATIONS.UPDATE_POSITION, BLOCK_OPERATIONS.UPDATE_NAME, + BLOCK_OPERATIONS.UPDATE_DESCRIPTION, BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx index 27ace3935de..e574b958e7d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { createContext, use, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' +import { FROSTED_GLASS_SURFACE } from '@/lib/ui/glass-surface' /** * Frosted near-white surface for the scrolled bar - `--bg` at 92% + a strong 40px @@ -10,8 +11,7 @@ import { cn } from '@sim/emcn' * dropdown sheet ({@link MobileNav}) wears the exact same glass as the bar and the * two can never drift. */ -export const NAVBAR_GLASS_SURFACE = - 'bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-2xl' +export const NAVBAR_GLASS_SURFACE = FROSTED_GLASS_SURFACE interface NavbarFrostContextValue { /** diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 6f5d5ff5705..4effe597eae 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -6,12 +6,7 @@ import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' -import { - isChatEnabled, - isHosted, - isReactGrabEnabled, - isReactScanEnabled, -} from '@/lib/core/config/env-flags' +import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/env-flags' import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' @@ -155,10 +150,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } var activeTab = panelState && panelState.activeTab; - // A session that used the Chat tab before it was turned off still - // has 'copilot' persisted; without this the CSS hides every tab - // body and the panel paints empty. - if (activeTab === 'copilot' && !${isChatEnabled}) { + // Chat moved out of the right inspector. Migrate the legacy + // persisted tab before first paint so the inspector opens on Blocks. + if (activeTab === 'copilot') { activeTab = 'toolbar'; } if (activeTab) { @@ -260,7 +254,10 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= {isHosted ? : } - + {/* Google Tag Manager (noscript) — hosted only */} {isHosted && (