improvement(preview): error paths, loops, workflow (#3010)

* improvement(switch): dark styling

* improvement(settings): change deployed MCPs to MCPs servers

* improvement(preview): added error paths, loop logic

* improvement(preview): nested workflows preview

* feat(preview): lightweight param

* improvement(preview): staging changes integrated
This commit is contained in:
Emir Karabeg
2026-01-26 17:57:48 -08:00
committed by GitHub
parent cb650132c7
commit 9cba8eee48
37 changed files with 1543 additions and 1066 deletions

View File

@@ -377,6 +377,16 @@ aside[data-sidebar] > *:not([data-sidebar-viewport]) {
button[aria-label="Toggle Sidebar"],
button[aria-label="Collapse Sidebar"],
/* Hide nav title/logo in sidebar on desktop - target all possible locations */
/* Lower specificity selectors first (attribute selectors) */
[data-sidebar-header],
[data-sidebar] [data-title],
aside[data-sidebar] a[href="/"],
aside[data-sidebar] a[href="/"] img,
aside[data-sidebar] > a:first-child,
aside[data-sidebar] > div > a:first-child,
aside[data-sidebar] img[alt="Sim"],
aside[data-sidebar] svg[aria-label="Sim"],
/* Higher specificity selectors (ID selectors) */
#nd-sidebar
a[href="/"],
#nd-sidebar a[href="/"] img,
@@ -385,14 +395,6 @@ aside[data-sidebar] > *:not([data-sidebar-viewport]) {
#nd-sidebar > div:first-child > a:first-child,
#nd-sidebar img[alt="Sim"],
#nd-sidebar svg[aria-label="Sim"],
aside[data-sidebar] a[href="/"],
aside[data-sidebar] a[href="/"] img,
aside[data-sidebar] > a:first-child,
aside[data-sidebar] > div > a:first-child,
aside[data-sidebar] img[alt="Sim"],
aside[data-sidebar] svg[aria-label="Sim"],
[data-sidebar-header],
[data-sidebar] [data-title],
/* Hide theme toggle at bottom of sidebar on desktop */
#nd-sidebar
> footer,

View File

@@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
1. Navigieren Sie zu **Einstellungen → MCP-Server**
2. Klicken Sie auf **Server erstellen**
3. Geben Sie einen Namen und eine optionale Beschreibung ein
4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients
@@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu,
## Server-Verwaltung
In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie:
In der Server-Detailansicht unter **Einstellungen → MCP-Server** können Sie:
- **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen

View File

@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
</div>
1. Navigieren Sie zu Ihren Workspace-Einstellungen
2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs**
2. Gehen Sie zum Abschnitt **MCP-Server**
3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration

View File

@@ -16,7 +16,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigate to **Settings → Deployed MCPs**
1. Navigate to **Settings → MCP Servers**
2. Click **Create Server**
3. Enter a name and optional description
4. Copy the server URL for use in your MCP clients
@@ -78,7 +78,7 @@ Include your API key header (`X-API-Key`) for authenticated access when using mc
## Server Management
From the server detail view in **Settings → Deployed MCPs**, you can:
From the server detail view in **Settings → MCP Servers**, you can:
- **View tools**: See all workflows added to a server
- **Copy URL**: Get the server URL for MCP clients

View File

@@ -27,7 +27,7 @@ MCP servers provide collections of tools that your agents can use. Configure the
</div>
1. Navigate to your workspace settings
2. Go to the **Deployed MCPs** section
2. Go to the **MCP Servers** section
3. Click **Add MCP Server**
4. Enter the server configuration details
5. Save the configuration

View File

@@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navega a **Configuración → MCP implementados**
1. Navega a **Configuración → Servidores MCP**
2. Haz clic en **Crear servidor**
3. Introduce un nombre y una descripción opcional
4. Copia la URL del servidor para usarla en tus clientes MCP
@@ -79,7 +79,7 @@ Incluye tu encabezado de clave API (`X-API-Key`) para acceso autenticado al usar
## Gestión del servidor
Desde la vista de detalles del servidor en **Configuración → MCP implementados**, puedes:
Desde la vista de detalles del servidor en **Configuración → Servidores MCP**, puedes:
- **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor
- **Copiar URL**: obtén la URL del servidor para clientes MCP

View File

@@ -27,7 +27,7 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
</div>
1. Navega a la configuración de tu espacio de trabajo
2. Ve a la sección **MCP implementados**
2. Ve a la sección **Servidores MCP**
3. Haz clic en **Añadir servidor MCP**
4. Introduce los detalles de configuración del servidor
5. Guarda la configuración

View File

@@ -17,7 +17,7 @@ Les serveurs MCP regroupent vos outils de workflow. Créez-les et gérez-les dan
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Accédez à **Paramètres → MCP déployés**
1. Accédez à **Paramètres → Serveurs MCP**
2. Cliquez sur **Créer un serveur**
3. Saisissez un nom et une description facultative
4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP
@@ -79,7 +79,7 @@ Incluez votre en-tête de clé API (`X-API-Key`) pour un accès authentifié lor
## Gestion du serveur
Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, vous pouvez :
Depuis la vue détaillée du serveur dans **Paramètres → Serveurs MCP**, vous pouvez :
- **Voir les outils** : voir tous les workflows ajoutés à un serveur
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP

View File

@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
</div>
1. Accédez aux paramètres de votre espace de travail
2. Allez dans la section **MCP déployés**
2. Allez dans la section **Serveurs MCP**
3. Cliquez sur **Ajouter un serveur MCP**
4. Saisissez les détails de configuration du serveur
5. Enregistrez la configuration

View File

@@ -16,7 +16,7 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. **設定 → デプロイ済みMCP**に移動します
1. **設定 → MCP サーバー**に移動します
2. **サーバーを作成**をクリックします
3. 名前とオプションの説明を入力します
4. MCPクライアントで使用するためにサーバーURLをコピーします
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
## サーバー管理
**設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます:
**設定 → MCP サーバー**のサーバー詳細ビューから、次のことができます:
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
- **URLをコピー**: MCPクライアント用のサーバーURLを取得

View File

@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
</div>
1. ワークスペース設定に移動します
2. **デプロイ済みMCP**セクションに移動します
2. **MCP サーバー**セクションに移動します
3. **MCPサーバーを追加**をクリックします
4. サーバー設定の詳細を入力します
5. 設定を保存します

View File

@@ -16,7 +16,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. 进入 **设置 → 已部署的 MCPs**
1. 进入 **设置 → MCP 服务器**
2. 点击 **创建服务器**
3. 输入名称和可选描述
4. 复制服务器 URL 以在你的 MCP 客户端中使用
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
在 **设置 → MCP 服务器** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL

View File

@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
</div>
1. 进入您的工作区设置
2. 前往 **Deployed MCPs** 部分
2. 前往 **MCP Servers** 部分
3. 点击 **Add MCP Server**
4. 输入服务器配置信息
5. 保存配置

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
@@ -330,7 +330,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
try {
return (
<WorkflowPreview
<PreviewWorkflow
workflowState={template.state}
height='100%'
width='100%'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -200,7 +200,7 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
<PreviewWorkflow
workflowState={normalizedState}
height={180}
width='100%'

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
@@ -13,13 +13,8 @@ import {
PopoverContent,
PopoverItem,
} from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -32,13 +27,6 @@ interface TraceSpan {
children?: TraceSpan[]
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState {
_migrated: true
_note?: string
@@ -70,98 +58,35 @@ export function ExecutionSnapshot({
onClose = () => {},
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = useRef<string | null>(null)
const lastExecutionIdRef = useRef<string | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => {
setIsMenuOpen(false)
setContextMenuBlockId(null)
}, [])
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuBlockId(null)
setMenuPosition({ x: e.clientX, y: e.clientY })
setIsMenuOpen(true)
}, [])
const handleNodeContextMenu = useCallback(
(blockId: string, mousePosition: { x: number; y: number }) => {
setContextMenuBlockId(blockId)
setMenuPosition(mousePosition)
setIsMenuOpen(true)
},
[]
)
const handleCopyExecutionId = useCallback(() => {
navigator.clipboard.writeText(executionId)
closeMenu()
}, [executionId, closeMenu])
const handleOpenDetails = useCallback(() => {
if (contextMenuBlockId) {
setPinnedBlockId(contextMenuBlockId)
}
closeMenu()
}, [contextMenuBlockId, closeMenu])
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
const workflowState = data?.workflowState as WorkflowState | undefined
// Auto-select the leftmost block once when data loads for a new executionId
useEffect(() => {
if (
workflowState &&
!isMigratedWorkflowState(workflowState) &&
autoSelectedForExecutionRef.current !== executionId
) {
autoSelectedForExecutionRef.current = executionId
const leftmostId = getLeftmostBlockId(workflowState)
setPinnedBlockId(leftmostId)
}
}, [executionId, workflowState])
// Track execution ID changes for key reset
const executionKey = executionId !== lastExecutionIdRef.current ? executionId : undefined
if (executionId !== lastExecutionIdRef.current) {
lastExecutionIdRef.current = executionId
}
const renderContent = () => {
if (isLoading) {
@@ -226,44 +151,17 @@ export function ExecutionSnapshot({
}
return (
<div
style={{ height, width }}
className={cn(
'flex overflow-hidden',
!isModal && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
<WorkflowPreview
workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={() => setPinnedBlockId(null)}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode
onClose={() => setPinnedBlockId(null)}
/>
)}
</div>
<Preview
key={executionKey}
workflowState={workflowState}
traceSpans={traceSpans}
className={className}
height={height}
width={width}
onCanvasContextMenu={handleCanvasContextMenu}
showBorder={!isModal}
autoSelectLeftmost
/>
)
}
@@ -287,9 +185,6 @@ export function ExecutionSnapshot({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{contextMenuBlockId && (
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
)}
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
</PopoverContent>
</Popover>,
@@ -304,7 +199,6 @@ export function ExecutionSnapshot({
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}

View File

@@ -3,7 +3,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -206,7 +206,7 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
<PreviewWorkflow
workflowState={normalizedState}
height={180}
width='100%'

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -17,11 +17,7 @@ import {
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components'
@@ -59,8 +55,6 @@ export function GeneralDeploy({
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const hasAutoSelectedRef = useRef(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
@@ -135,19 +129,6 @@ export function GeneralDeploy({
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
// Auto-select the leftmost block once when expanded preview opens
useEffect(() => {
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true
const leftmostId = getLeftmostBlockId(workflowToShow)
setExpandedSelectedBlockId(leftmostId)
}
// Reset when modal closes
if (!showExpandedPreview) {
hasAutoSelectedRef.current = false
}
}, [showExpandedPreview, workflowToShow])
if (showLoadingSkeleton) {
return (
<div className='space-y-[12px]'>
@@ -205,7 +186,7 @@ export function GeneralDeploy({
{workflowToShow ? (
<>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<WorkflowPreview
<PreviewWorkflow
workflowState={workflowToShow}
height='100%'
width='100%'
@@ -306,46 +287,15 @@ export function GeneralDeploy({
</Modal>
{workflowToShow && (
<Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>
{previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>
<div className='flex h-full w-full overflow-hidden'>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(blockId)
}}
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}
parallels={workflowToShow.parallels}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
<Preview workflowState={workflowToShow} autoSelectLeftmost />
</ModalBody>
</ModalContent>
</Modal>

View File

@@ -435,7 +435,7 @@ export function McpDeploy({
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings Deployed MCPs first.
Create an MCP Server in Settings MCP Servers first.
</p>
<Button
variant='tertiary'

View File

@@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
@@ -439,7 +439,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
}}
aria-hidden='true'
>
<WorkflowPreview
<PreviewWorkflow
workflowState={workflowState}
height='100%'
width='100%'
@@ -478,7 +478,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
}
return (
<WorkflowPreview
<PreviewWorkflow
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
height='100%'

View File

@@ -38,7 +38,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -458,7 +458,7 @@ export function Editor() {
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<WorkflowPreview
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
width='100%'

View File

@@ -6,6 +6,7 @@ import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
/** Extended edge props with optional handle identifiers */
interface WorkflowEdgeProps extends EdgeProps {
sourceHandle?: string | null
targetHandle?: string | null
@@ -90,15 +91,17 @@ const WorkflowEdgeComponent = ({
if (edgeDiffStatus === 'deleted') {
color = 'var(--text-error)'
opacity = 0.7
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary-2)'
} else if (edgeRunStatus === 'success') {
// Use green for preview mode, default for canvas execution
// This also applies to error edges that were taken (error path executed)
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
} else if (edgeRunStatus === 'error') {
color = 'var(--text-error)'
} else if (isErrorEdge) {
// Error edges that weren't taken stay red
color = 'var(--text-error)'
}
if (isSelected) {
@@ -151,4 +154,14 @@ const WorkflowEdgeComponent = ({
)
}
/**
* Workflow edge component with execution status and diff visualization.
*
* @remarks
* Edge coloring priority:
* 1. Diff status (deleted/new) - for version comparison
* 2. Execution status (success/error) - for run visualization
* 3. Error edge default (red) - for untaken error paths
* 4. Default edge color - normal workflow connections
*/
export const WorkflowEdge = memo(WorkflowEdgeComponent)

View File

@@ -0,0 +1 @@
export { PreviewContextMenu } from './preview-context-menu'

View File

@@ -0,0 +1,97 @@
'use client'
import type { RefObject } from 'react'
import { createPortal } from 'react-dom'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface PreviewContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopy: () => void
onSearch?: () => void
wrapText?: boolean
onToggleWrap?: () => void
/** When true, only shows Copy option (for subblock values) */
copyOnly?: boolean
}
/**
* Context menu for preview editor sidebar.
* Provides copy, search, and display options.
* Uses createPortal to render outside any transformed containers (like modals).
*/
export function PreviewContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopy,
onSearch,
wrapText,
onToggleWrap,
copyOnly = false,
}: PreviewContextMenuProps) {
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onCopy()
onClose()
}}
>
Copy
</PopoverItem>
{!copyOnly && onSearch && (
<>
<PopoverDivider />
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
</>
)}
{!copyOnly && onToggleWrap && (
<>
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>,
document.body
)
}

View File

@@ -0,0 +1 @@
export { PreviewEditor } from './preview-editor'

View File

@@ -6,12 +6,24 @@ import {
ArrowUp,
ChevronDown as ChevronDownIcon,
ChevronUp,
ExternalLink,
Maximize2,
RepeatIcon,
SplitIcon,
X,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import {
Badge,
Button,
ChevronDown,
Code,
Combobox,
Input,
Label,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
@@ -22,15 +34,42 @@ import {
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useWorkflowState } from '@/hooks/queries/workflows'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
/**
* CSS override to show full opacity and prevent interaction in readonly preview mode.
* Extracted to avoid duplicating the style block in multiple places.
*/
const READONLY_PREVIEW_STYLES = `
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`
/**
* Format a value for display as JSON string
@@ -123,44 +162,31 @@ function formatInlineValue(value: unknown): string {
return String(value)
}
interface ExecutionDataSectionProps {
interface CollapsibleSectionProps {
title: string
data: unknown
defaultExpanded?: boolean
children: React.ReactNode
isEmpty?: boolean
emptyMessage?: string
/** Whether this section represents an error state (styles title red) */
isError?: boolean
wrapText?: boolean
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
onContextMenu?: (e: React.MouseEvent) => void
}
/**
* Collapsible section for execution data (input/output)
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
* Collapsible section wrapper for organizing preview editor content
*/
function ExecutionDataSection({
function CollapsibleSection({
title,
data,
defaultExpanded = false,
children,
isEmpty = false,
emptyMessage = 'No data',
isError = false,
wrapText = true,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
onContextMenu,
}: ExecutionDataSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const jsonString = useMemo(() => {
if (!data) return ''
return formatValueAsJson(data)
}, [data])
const isEmpty = jsonString === '—' || jsonString === ''
}: CollapsibleSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)}
@@ -199,20 +225,10 @@ function ExecutionDataSection({
<>
{isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
<span className='text-[12px] text-[var(--text-tertiary)]'>{emptyMessage}</span>
</div>
) : (
<div onContextMenu={onContextMenu} ref={contentRef}>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
/>
</div>
children
)}
</>
)}
@@ -261,9 +277,12 @@ function ConnectionsSection({
const [expandedVariables, setExpandedVariables] = useState(true)
const [expandedEnvVars, setExpandedEnvVars] = useState(true)
/** Stable string of connection IDs to prevent effect from running on every render */
const connectionIds = useMemo(() => connections.map((c) => c.blockId).join(','), [connections])
useEffect(() => {
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
}, [connections])
setExpandedBlocks(new Set(connectionIds.split(',').filter(Boolean)))
}, [connectionIds])
const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0
@@ -549,27 +568,22 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const isLoop = block.type === 'loop'
const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel
// Determine current type
const currentType = isLoop
? loop?.loopType || (block.data?.loopType as string) || 'for'
: parallel?.parallelType || (block.data?.parallelType as string) || 'count'
// Build type options for combobox - matches SubflowEditor
const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({
value,
label,
}))
// Determine mode
const isCountMode = currentType === 'for' || currentType === 'count'
const isConditionMode = currentType === 'while' || currentType === 'doWhile'
// Get iterations value
const iterations = isLoop
? (loop?.iterations ?? (block.data?.count as number) ?? 5)
: (parallel?.count ?? (block.data?.count as number) ?? 1)
// Get collection/condition value
const getEditorValue = (): string => {
if (isConditionMode && isLoop) {
if (currentType === 'while') {
@@ -589,7 +603,6 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
const editorValue = getEditorValue()
// Get label for configuration field - matches SubflowEditor exactly
const getConfigLabel = (): string => {
if (isCountMode) {
return `${isLoop ? 'Loop' : 'Parallel'} Iterations`
@@ -601,7 +614,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
}
return (
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[5px] pb-[8px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden pt-[8px] pb-[8px]'>
{/* Type Selection - matches SubflowEditor */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -703,6 +716,8 @@ interface PreviewEditorProps {
isExecutionMode?: boolean
/** Optional close handler - if not provided, no close button is shown */
onClose?: () => void
/** Callback to drill down into a nested workflow block */
onDrillDown?: (blockId: string, childWorkflowState: WorkflowState) => void
}
/** Minimum height for the connections section (header only) */
@@ -725,8 +740,8 @@ function PreviewEditorContent({
parallels,
isExecutionMode = false,
onClose,
onDrillDown,
}: PreviewEditorProps) {
// Convert Record<string, Variable> to Array<Variable> for iteration
const normalizedWorkflowVariables = useMemo(() => {
if (!workflowVariables) return []
return Object.values(workflowVariables)
@@ -735,10 +750,39 @@ function PreviewEditorContent({
const blockConfig = getBlock(block.type) as BlockConfig | undefined
const subBlockValues = block.subBlocks || {}
const params = useParams()
const workspaceId = params.workspaceId as string
const isWorkflowBlock = block.type === 'workflow' || block.type === 'workflow_input'
/** Extracts child workflow ID from subblock values for workflow blocks */
const childWorkflowId = useMemo(() => {
if (!isWorkflowBlock) return null
const workflowIdValue = subBlockValues?.workflowId
if (workflowIdValue && typeof workflowIdValue === 'object' && 'value' in workflowIdValue) {
return (workflowIdValue as { value: unknown }).value as string | null
}
return workflowIdValue as string | null
}, [isWorkflowBlock, subBlockValues?.workflowId])
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined
)
/** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId || !childWorkflowState) return
if (isExecutionMode && onDrillDown) {
onDrillDown(block.id, childWorkflowState)
} else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null)
// Connections resize state
const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT)
const [isResizing, setIsResizing] = useState(false)
const startYRef = useRef<number>(0)
@@ -846,10 +890,8 @@ function PreviewEditorContent({
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up
const deltaY = startYRef.current - e.clientY
let newHeight = startHeightRef.current + deltaY
// Clamp height between fixed min and max for stable behavior
newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight))
setConnectionsHeight(newHeight)
}
@@ -871,7 +913,6 @@ function PreviewEditorContent({
}
}, [isResizing])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5
const blockNameToId = useMemo(() => {
@@ -891,7 +932,7 @@ function PreviewEditorContent({
if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1) // Remove < and >
const inner = reference.slice(1, -1)
const parts = inner.split('.')
if (parts.length < 1) return undefined
@@ -1007,12 +1048,10 @@ function PreviewEditorContent({
[blockConfig?.subBlocks]
)
// Check if this is a subflow block (loop or parallel)
const isSubflow = block.type === 'loop' || block.type === 'parallel'
const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined
const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined
// Handle subflow blocks
if (isSubflow) {
const isLoop = block.type === 'loop'
const SubflowIcon = isLoop ? RepeatIcon : SplitIcon
@@ -1043,27 +1082,7 @@ function PreviewEditorContent({
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'>
<div className='readonly-preview px-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
<style>{READONLY_PREVIEW_STYLES}</style>
<SubflowConfigDisplay block={block} loop={loopConfig} parallel={parallelConfig} />
</div>
</div>
@@ -1095,8 +1114,6 @@ function PreviewEditorContent({
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
@@ -1145,7 +1162,7 @@ function PreviewEditorContent({
{/* Content area */}
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subblocks Section */}
{/* Main content sections */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
@@ -1159,91 +1176,154 @@ function PreviewEditorContent({
</div>
)}
{/* Execution Input/Output (if provided) */}
{executionData &&
(executionData.input !== undefined || executionData.output !== undefined) ? (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{/* Execution Status & Duration Header */}
{(executionData.status || executionData.durationMs !== undefined) && (
<div className='flex items-center justify-between'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
{/* Execution Status & Duration Header */}
{executionData && (executionData.status || executionData.durationMs !== undefined) && (
<div className='flex min-w-0 items-center justify-between overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{/* Divider between Status/Duration and Input/Output */}
{(executionData.status || executionData.durationMs !== undefined) &&
(executionData.input !== undefined || executionData.output !== undefined) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Input Section */}
{executionData.input !== undefined && (
<ExecutionDataSection
title='Input'
data={executionData.input}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/>
)}
{/* Divider between Input and Output */}
{executionData.input !== undefined && executionData.output !== undefined && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Output Section */}
{executionData.output !== undefined && (
<ExecutionDataSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
data={executionData.output}
isError={executionData.status === 'error'}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={contentRef}
onContextMenu={handleExecutionContextMenu}
/>
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs, { precision: 2 })}
</span>
)}
</div>
) : null}
)}
{/* Input Section - Collapsible */}
{executionData?.input !== undefined && (
<CollapsibleSection
title='Input'
defaultExpanded={false}
isEmpty={
formatValueAsJson(executionData.input) === '—' ||
formatValueAsJson(executionData.input) === ''
}
emptyMessage='No input data'
>
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
<Code.Viewer
code={formatValueAsJson(executionData.input)}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
</div>
</CollapsibleSection>
)}
{/* Output Section - Collapsible, expanded by default */}
{executionData?.output !== undefined && (
<CollapsibleSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
defaultExpanded={true}
isEmpty={
formatValueAsJson(executionData.output) === '—' ||
formatValueAsJson(executionData.output) === ''
}
emptyMessage='No output data'
isError={executionData.status === 'error'}
>
<div onContextMenu={handleExecutionContextMenu}>
<Code.Viewer
code={formatValueAsJson(executionData.output)}
language='json'
className={cn(
'!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]',
executionData.status === 'error' && 'text-[var(--text-error)]'
)}
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
</div>
</CollapsibleSection>
)}
{/* Workflow Preview - only for workflow blocks with a selected child workflow */}
{isWorkflowBlock && childWorkflowId && (
<div className='px-[8px] pt-[12px]'>
<div className='subblock-content flex flex-col gap-[9.5px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)] leading-none'>
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
) : childWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow
workflowState={childWorkflowState}
height={160}
width='100%'
isPannable={true}
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleExpandChildWorkflow}
className='absolute right-[6px] bottom-[6px] z-10 h-[24px] w-[24px] cursor-pointer border border-[var(--border)] bg-[var(--surface-2)] p-0 hover:bg-[var(--surface-4)]'
>
{isExecutionMode && onDrillDown ? (
<Maximize2 className='h-[12px] w-[12px]' />
) : (
<ExternalLink className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isExecutionMode && onDrillDown ? 'Expand workflow' : 'Open in new tab'}
</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
</span>
</div>
)}
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</div>
)}
{/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] py-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
<div className='readonly-preview px-[8px] pt-[12px] pb-[8px]'>
<style>{READONLY_PREVIEW_STYLES}</style>
{visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => (
@@ -1349,7 +1429,7 @@ function PreviewEditorContent({
)}
{/* Context Menu */}
<SnapshotContextMenu
<PreviewContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}

View File

@@ -1,7 +1,6 @@
'use client'
import { memo, useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { type CSSProperties, memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
@@ -11,17 +10,40 @@ import {
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import type { ExecutionStatus } from '@/app/workspace/[workspaceId]/w/components/preview/preview'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Subblock value structure matching workflow state */
interface SubBlockValueEntry {
value: unknown
}
/**
* Handle style constants for preview blocks.
* Extracted to avoid recreating style objects on each render.
*/
const HANDLE_STYLES = {
horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]',
vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]',
right:
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
error:
'!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
} as const
/** Reusable style object for error handles positioned at bottom-right */
const ERROR_HANDLE_STYLE: CSSProperties = {
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}
interface WorkflowPreviewBlockData {
type: string
name: string
@@ -34,16 +56,8 @@ interface WorkflowPreviewBlockData {
executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/** Skips expensive subblock computations for thumbnails */
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
/** Whether this is a subflow container (loop/parallel) */
isSubflow?: boolean
/** Type of subflow container */
subflowKind?: 'loop' | 'parallel'
/** Width of subflow container */
width?: number
/** Height of subflow container */
height?: number
}
/**
@@ -166,21 +180,17 @@ function resolveToolsDisplay(
if (!tool || typeof tool !== 'object') return null
const t = tool as Record<string, unknown>
// Priority 1: Use tool.title if already populated
if (t.title && typeof t.title === 'string') return t.title
// Priority 2: Extract from inline schema (legacy format)
const schema = t.schema as Record<string, unknown> | undefined
if (schema?.function && typeof schema.function === 'object') {
const fn = schema.function as Record<string, unknown>
if (fn.name && typeof fn.name === 'string') return fn.name
}
// Priority 3: Extract from OpenAI function format
const fn = t.function as Record<string, unknown> | undefined
if (fn?.name && typeof fn.name === 'string') return fn.name
// Priority 4: Resolve built-in tool blocks from registry
if (
typeof t.type === 'string' &&
t.type !== 'custom-tool' &&
@@ -246,115 +256,11 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
)
}
interface SubflowContainerProps {
name: string
width?: number
height?: number
kind: 'loop' | 'parallel'
isPreviewSelected?: boolean
}
/**
* Renders a subflow container (loop/parallel) for preview mode.
*/
function SubflowContainer({
name,
width = 500,
height = 300,
kind,
isPreviewSelected = false,
}: SubflowContainerProps) {
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
style={{ width, height }}
>
{/* Selection ring overlay */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
{blockName}
</span>
</div>
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
}
/**
* Preview block component for workflow visualization.
* Renders block header, subblock values, and handles without
* hooks, store subscriptions, or interactive features.
* Matches the visual structure of WorkflowBlock exactly.
* Also handles subflow containers (loop/parallel) when isSubflow is true.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const {
@@ -367,32 +273,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
executionStatus,
subBlockValues,
lightweight = false,
isSubflow = false,
subflowKind,
width,
height,
} = data
if (isSubflow && subflowKind) {
return (
<SubflowContainer
name={name}
width={width}
height={height}
kind={subflowKind}
isPreviewSelected={isPreviewSelected}
/>
)
}
const blockConfig = getBlock(type)
const canonicalIndex = useMemo(
() =>
lightweight
? { groupsById: {}, canonicalIdBySubBlockId: {} }
: buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks, lightweight]
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const rawValues = useMemo(() => {
@@ -404,10 +291,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
if (lightweight || !blockConfig?.subBlocks) return []
if (!blockConfig?.subBlocks) return []
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => {
@@ -424,26 +310,40 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
if (subBlock.mode === 'trigger') return false
}
/** Skip value-dependent visibility checks in lightweight mode */
if (lightweight) return !subBlock.condition
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
}, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.category,
blockConfig?.triggers?.enabled,
blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
/**
* Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const conditionRows = useMemo(() => {
if (lightweight || type !== 'condition') return []
if (type !== 'condition') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
if (lightweight) return defaultRows
const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -464,17 +364,23 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
// Failed to parse, use fallback
/* empty */
}
return [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
}, [lightweight, type, rawValues])
return defaultRows
}, [type, rawValues, lightweight])
/**
* Compute router rows for router_v2 blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const routerRows = useMemo(() => {
if (lightweight || type !== 'router_v2') return []
if (type !== 'router_v2') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [{ id: 'route1', value: '' }]
if (lightweight) return defaultRows
const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined
@@ -493,11 +399,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
}
} catch {
// Failed to parse, use fallback
/* empty */
}
return [{ id: 'route1', value: '' }]
}, [lightweight, type, rawValues])
return defaultRows
}, [type, rawValues, lightweight])
if (!blockConfig) {
return null
@@ -515,9 +421,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
@@ -542,7 +445,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -576,32 +479,36 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
<div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition' ? (
conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
<SubBlockRow
key={cond.id}
title={cond.title}
value={lightweight ? undefined : getDisplayValue(cond.value)}
/>
))
) : type === 'router_v2' ? (
<>
<SubBlockRow
key='context'
title='Context'
value={getDisplayValue(rawValues.context)}
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={getDisplayValue(route.value)}
value={lightweight ? undefined : getDisplayValue(route.value)}
/>
))}
</>
) : (
visibleSubBlocks.map((subBlock) => {
const rawValue = rawValues[subBlock.id]
const rawValue = lightweight ? undefined : rawValues[subBlock.id]
return (
<SubBlockRow
key={subBlock.id}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
/>
)
@@ -612,7 +519,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</div>
)}
{/* Condition block handles - one per condition branch + error */}
{/* Condition block handles */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
@@ -624,8 +531,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={horizontalHandleClass}
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
@@ -633,22 +540,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Router block handles - one per route + error */}
{/* Router block handles */}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
// +1 row offset for context row at the top
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
@@ -658,8 +559,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={horizontalHandleClass}
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
@@ -667,25 +568,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Standard block handles - source + error (not for condition, router, or response) */}
{/* Source and error handles for non-condition/router blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -697,13 +593,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</>
@@ -712,6 +603,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
)
}
/**
* Custom comparison function for React.memo optimization.
* Uses fast-path primitive comparison before shallow comparing subBlockValues.
* @param prevProps - Previous render props
* @param nextProps - Next render props
* @returns True if render should be skipped (props are equal)
*/
function shouldSkipPreviewBlockRender(
prevProps: NodeProps<WorkflowPreviewBlockData>,
nextProps: NodeProps<WorkflowPreviewBlockData>
@@ -725,40 +623,40 @@ function shouldSkipPreviewBlockRender(
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.lightweight !== nextProps.data.lightweight ||
prevProps.data.isSubflow !== nextProps.data.isSubflow ||
prevProps.data.subflowKind !== nextProps.data.subflowKind ||
prevProps.data.width !== nextProps.data.width ||
prevProps.data.height !== nextProps.data.height
prevProps.data.lightweight !== nextProps.data.lightweight
) {
return false
}
/** Skip subBlockValues comparison in lightweight mode */
if (nextProps.data.lightweight) return true
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
if (prevValues === nextValues) {
return true
}
if (!prevValues || !nextValues) {
return false
}
if (prevValues === nextValues) return true
if (!prevValues || !nextValues) return false
const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues)
if (prevKeys.length !== nextKeys.length) {
return false
}
if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
if (prevValues[key] !== nextValues[key]) {
return false
}
if (prevValues[key] !== nextValues[key]) return false
}
return true
}
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
/**
* Preview block component for workflow visualization in readonly contexts.
* Optimized for rendering without hooks or store subscriptions.
*
* @remarks
* - Renders block header, subblock values, and connection handles
* - Supports condition, router, and standard block types
* - Shows error handles for non-trigger blocks
* - Displays execution status via colored ring overlays
*/
export const PreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)

View File

@@ -0,0 +1 @@
export { PreviewBlock } from './block'

View File

@@ -0,0 +1 @@
export { PreviewSubflow } from './subflow'

View File

@@ -0,0 +1,131 @@
'use client'
import { memo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
/** Execution status for subflows in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewSubflowData {
name: string
width?: number
height?: number
kind: 'loop' | 'parallel'
/** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean
/** Execution status for highlighting the subflow container */
executionStatus?: ExecutionStatus
/** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
lightweight?: boolean
}
/**
* Preview subflow component for workflow visualization.
* Renders loop/parallel containers without hooks, store subscriptions,
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
style={{
width,
height,
}}
>
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-tertiary-2)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--text-error)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
{blockName}
</span>
</div>
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
}
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)

View File

@@ -0,0 +1 @@
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'

View File

@@ -0,0 +1,613 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import ReactFlow, {
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('PreviewWorkflow')
/**
* Gets block dimensions for preview purposes.
* For containers, uses stored dimensions or defaults.
* For regular blocks, uses stored height or estimates based on type.
*/
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') {
return {
width: block.data?.width
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
}
/**
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
*/
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childBlocks) {
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}
/**
* Finds the leftmost block ID from a workflow state.
* Excludes subflow containers (loop/parallel) from consideration.
* @param workflowState - The workflow state to search
* @returns The ID of the leftmost block, or null if no blocks exist
*/
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || block.type === 'loop' || block.type === 'parallel') continue
const x = block.position?.x ?? Number.POSITIVE_INFINITY
if (x < minX) {
minX = x
leftmostId = blockId
}
}
return leftmostId
}
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Calculates absolute position for blocks, handling nested subflows */
function calculateAbsolutePosition(
block: BlockState,
blocks: Record<string, BlockState>
): { x: number; y: number } {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
interface PreviewWorkflowProps {
workflowState: WorkflowState
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when a node is right-clicked */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
/**
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
*/
const previewNodeTypes: NodeTypes = {
workflowBlock: PreviewBlock,
noteBlock: PreviewBlock,
subflowNode: PreviewSubflow,
}
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge,
}
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
}
}, [containerRef, fitPadding, fitView])
return null
}
/**
* Readonly workflow component for visualizing workflow state.
* Renders blocks, subflows, and edges with execution status highlighting.
*
* @remarks
* - Supports panning and node click interactions
* - Shows execution path via green edges for successful paths
* - Error edges display red by default, green when error path was taken
* - Fits view automatically when nodes change or container resizes
*/
export function PreviewWorkflow({
workflowState,
className,
height = '100%',
width = '100%',
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onNodeContextMenu,
onPaneClick,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
lightweight = false,
}: PreviewWorkflowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}
}, [workflowState.blocks, isValidWorkflowState])
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}
}, [workflowState.loops, isValidWorkflowState])
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}
}, [workflowState.parallels, isValidWorkflowState])
/** Map of subflow ID to child block IDs */
const subflowChildrenMap = useMemo(() => {
if (!isValidWorkflowState) return new Map<string, string[]>()
const map = new Map<string, string[]>()
for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
const parentId = block?.data?.parentId
if (parentId) {
const children = map.get(parentId) || []
children.push(blockId)
map.set(parentId, children)
}
}
return map
}, [workflowState.blocks, isValidWorkflowState])
/** Derives subflow execution status from child blocks */
const getSubflowExecutionStatus = useMemo(() => {
return (subflowId: string): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined
const childIds = subflowChildrenMap.get(subflowId)
if (!childIds?.length) return undefined
const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean)
if (childStatuses.length === 0) return undefined
if (childStatuses.some((s) => s.status === 'error')) return 'error'
if (childStatuses.some((s) => s.status === 'success')) return 'success'
return 'not-executed'
}
}, [executedBlocks, subflowChildrenMap])
/** Gets execution status for any block, deriving subflow status from children */
const getBlockExecutionStatus = useMemo(() => {
return (blockId: string): { status: string; executed: boolean } | undefined => {
if (!executedBlocks) return undefined
const directStatus = executedBlocks[blockId]
if (directStatus) {
return { status: directStatus.status, executed: true }
}
const block = workflowState.blocks?.[blockId]
if (block && (block.type === 'loop' || block.type === 'parallel')) {
const subflowStatus = getSubflowExecutionStatus(blockId)
if (subflowStatus) {
return { status: subflowStatus, executed: true }
}
const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
return { status: 'not-executed', executed: true }
}
}
return undefined
}
}, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}
}, [workflowState.edges, isValidWorkflowState])
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
const subflowExecutionStatus = getSubflowExecutionStatus(blockId)
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: absolutePosition,
draggable: false,
data: {
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
executionStatus: subflowExecutionStatus,
lightweight,
},
})
return
}
const isSelected = selectedBlockId === blockId
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
lightweight,
},
})
})
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
getSubflowExecutionStatus,
lightweight,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/**
* Determines edge execution status for visualization.
* Error edges turn green when taken (source errored, target executed).
* Normal edges turn green when both source succeeded and target executed.
*/
const getEdgeExecutionStatus = (edge: {
source: string
target: string
sourceHandle?: string | null
}): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined
const sourceStatus = getBlockExecutionStatus(edge.source)
const targetStatus = getBlockExecutionStatus(edge.target)
const isErrorEdge = edge.sourceHandle === 'error'
if (isErrorEdge) {
return sourceStatus?.status === 'error' && targetStatus?.executed
? 'success'
: 'not-executed'
}
const isSubflowStartEdge =
edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
if (isSubflowStartEdge) {
const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
const incomingSucceeded = incomingEdge
? executedBlocks[incomingEdge.source]?.status === 'success'
: false
return incomingSucceeded ? 'success' : 'not-executed'
}
const targetBlock = workflowState.blocks?.[edge.target]
const targetIsSubflow =
targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
return 'success'
}
return 'not-executed'
}
return (workflowState.edges || []).map((edge) => {
const status = getEdgeExecutionStatus(edge)
const isErrorEdge = edge.sourceHandle === 'error'
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: {
...(status ? { executionStatus: status } : {}),
sourceHandle: edge.sourceHandle,
},
zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
}
})
}, [
edgesStructure,
workflowState.edges,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
getBlockExecutionStatus,
])
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
<style>{`
/* Canvas cursor - grab on the flow container and pane */
.preview-mode .react-flow { cursor: ${cursorStyle}; }
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={isPannable}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onNodeContextMenu={
onNodeContextMenu
? (event, node) => {
event.preventDefault()
event.stopPropagation()
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
/>
</div>
</ReactFlowProvider>
)
}

View File

@@ -1,2 +1,6 @@
export { PreviewContextMenu } from './components/preview-context-menu'
export { PreviewEditor } from './components/preview-editor'
export { getLeftmostBlockId, WorkflowPreview } from './preview'
export { getLeftmostBlockId, PreviewWorkflow } from './components/preview-workflow'
export { PreviewBlock } from './components/preview-workflow/components/block'
export { PreviewSubflow } from './components/preview-workflow/components/subflow'
export { buildBlockExecutions, Preview } from './preview'

View File

@@ -1,493 +1,292 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import ReactFlow, {
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
import {
getLeftmostBlockId,
PreviewWorkflow,
} from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
/**
* Gets block dimensions for preview purposes.
* For containers, uses stored dimensions or defaults.
* For regular blocks, uses stored height or estimates based on type.
*/
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') {
return {
width: block.data?.width
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
}
/**
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
* Accepts pre-filtered childBlocks for O(1) lookup instead of filtering all blocks.
*/
function calculateContainerDimensions(childBlocks: BlockState[]): {
width: number
height: number
} {
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childBlocks) {
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
/** Child trace spans for nested workflow blocks */
children?: TraceSpan[]
}
/**
* Finds the leftmost block ID from a workflow state.
* Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
*/
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || block.type === 'loop' || block.type === 'parallel') continue
const x = block.position?.x ?? Number.POSITIVE_INFINITY
if (x < minX) {
minX = x
leftmostId = blockId
}
}
return leftmostId
}
/**
* Recursively calculates the absolute position of a block,
* accounting for parent container offsets.
*/
function calculateAbsolutePosition(
block: BlockState,
blocks: Record<string, BlockState>
): { x: number; y: number } {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
/** Execution status for edges/nodes in the preview */
export type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
/** Represents a level in the workflow navigation stack */
interface WorkflowStackEntry {
workflowState: WorkflowState
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when a node is right-clicked */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive computations for thumbnails/template previews */
lightweight?: boolean
traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData>
}
/**
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
* Extracts child trace spans from a workflow block's execution data.
* Checks both the `children` property (where trace span processing moves them)
* and the legacy `output.childTraceSpans` for compatibility.
*/
const previewNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock,
}
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return []
// Define edge types
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge, // Keep for backward compatibility
}
if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
return blockExecution.children
}
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/**
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
// Fit view when nodes change
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
// Fit view when container resizes (debounced to avoid excessive calls during drag)
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
if (blockExecution.output && typeof blockExecution.output === 'object') {
const output = blockExecution.output as Record<string, unknown>
if (Array.isArray(output.childTraceSpans)) {
return output.childTraceSpans as TraceSpan[]
}
}, [containerRef, fitPadding, fitView])
}
return null
return []
}
export function WorkflowPreview({
workflowState,
/**
* Builds block execution data from trace spans
*/
export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockExecutionData> {
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (traceSpans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of traceSpans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(spans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
children: span.children,
}
}
}
return blockExecutionMap
}
interface PreviewProps {
/** The workflow state to display */
workflowState: WorkflowState
/** Trace spans for the execution (optional - enables execution mode features) */
traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData>
/** Additional CSS class names */
className?: string
/** Height of the component */
height?: string | number
/** Width of the component */
width?: string | number
/** Callback when canvas context menu is opened */
onCanvasContextMenu?: (e: React.MouseEvent) => void
/** Callback when a node context menu is opened */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Whether to show border around the component */
showBorder?: boolean
/** Initial block to select (defaults to leftmost block) */
initialSelectedBlockId?: string | null
/** Whether to auto-select the leftmost block on mount */
autoSelectLeftmost?: boolean
}
/**
* Main preview component that combines PreviewCanvas with PreviewEditor
* and handles nested workflow navigation via a stack.
*
* @remarks
* - Manages navigation stack for drilling into nested workflow blocks
* - Displays back button when viewing nested workflows
* - Properly passes execution data through to nested levels
* - Can be used anywhere a workflow preview with editor is needed
*/
export function Preview({
workflowState: rootWorkflowState,
traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions,
className,
height = '100%',
width = '100%',
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onCanvasContextMenu,
onNodeContextMenu,
onPaneClick,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
lightweight = false,
}: WorkflowPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const workflowStructureIds = useMemo(() => {
if (!isValidWorkflowState) return ''
const blockIds = Object.keys(workflowState.blocks || {})
const edgeIds = (workflowState.edges || []).map((e) => e.id)
return [...blockIds, ...edgeIds].join(',')
}, [workflowState.blocks, workflowState.edges, isValidWorkflowState])
// Pre-compute parent-child relationships for O(1) lookups in container dimension calculations
const containerChildIndex = useMemo(() => {
const index: Record<string, BlockState[]> = {}
for (const block of Object.values(workflowState.blocks || {})) {
if (block?.data?.parentId) {
const parentId = block.data.parentId
if (!index[parentId]) index[parentId] = []
index[parentId].push(block)
}
showBorder = false,
initialSelectedBlockId,
autoSelectLeftmost = true,
}: PreviewProps) {
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) {
return getLeftmostBlockId(rootWorkflowState)
}
return index
}, [workflowState.blocks])
return null
})
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
/** Stack for nested workflow navigation. Empty means we're at the root level. */
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
const nodeArray: Node[] = []
/** Block executions for the root level */
const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans])
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
/** Current block executions - either from stack or root */
const blockExecutions = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].blockExecutions
}
return rootBlockExecutions
}, [workflowStack, rootBlockExecutions])
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState
}
return rootWorkflowState
}, [workflowStack, rootWorkflowState])
// Handle loop/parallel containers - use unified workflowBlock with isSubflow flag
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const childBlocks = containerChildIndex[blockId] || []
const dimensions = calculateContainerDimensions(childBlocks)
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
data: {
type: block.type,
name: block.name,
isPreviewSelected: isSelected,
isSubflow: true,
subflowKind: block.type as 'loop' | 'parallel',
width: dimensions.width,
height: dimensions.height,
},
})
return
}
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0
}, [blockExecutions])
// Handle regular blocks
const isSelected = selectedBlockId === blockId
/** Handler to drill down into a nested workflow block */
const handleDrillDown = useCallback(
(blockId: string, childWorkflowState: WorkflowState) => {
const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
// Blocks inside subflows need higher z-index to appear above the container
zIndex: block.data?.parentId ? 10 : undefined,
data: {
type: block.type,
name: block.name,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
lightweight,
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
},
})
})
])
return nodeArray
}, [
workflowStructureIds,
workflowState.blocks,
containerChildIndex,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
lightweight,
])
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
},
[blockExecutions]
)
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null)
}, [])
return (workflowState.edges || []).map((edge) => {
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const sourceExecuted = executedBlocks[edge.source]
const targetExecuted = executedBlocks[edge.target]
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId)
}, [])
if (sourceExecuted && targetExecuted) {
// Edge is success if source succeeded and target was executed (even if target errored)
if (sourceExecuted.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
const handlePaneClick = useCallback(() => {
setPinnedBlockId(null)
}, [])
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: executionStatus ? { executionStatus } : undefined,
// Raise executed edges above default edges
zIndex: executionStatus === 'success' ? 10 : 0,
}
})
}, [workflowStructureIds, workflowState.edges, isValidWorkflowState, executedBlocks])
const handleEditorClose = useCallback(() => {
setPinnedBlockId(null)
}, [])
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
useEffect(() => {
setWorkflowStack([])
}, [rootWorkflowState])
const isNested = workflowStack.length > 0
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
<style>{`
/* Canvas cursor - grab on the flow container and pane */
.preview-mode .react-flow { cursor: ${cursorStyle}; }
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
<div
style={{ height, width }}
className={cn(
'relative flex overflow-hidden',
showBorder && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
{isNested && (
<div className='absolute top-[12px] left-[12px] z-20'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleGoBack}
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
>
<ArrowLeft className='h-[13px] w-[13px]' />
<span className='font-medium text-[13px]'>Back</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root>
</div>
)}
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={previewNodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={isPannable}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onNodeContextMenu={
onNodeContextMenu
? (event, node) => {
event.preventDefault()
event.stopPropagation()
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={workflowStructureIds}
fitPadding={fitPadding}
containerRef={containerRef}
<div className='h-full flex-1' onContextMenu={onCanvasContextMenu}>
<PreviewWorkflow
workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={handleNodeClick}
onNodeContextMenu={onNodeContextMenu}
onPaneClick={handlePaneClick}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
</ReactFlowProvider>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
onClose={handleEditorClose}
onDrillDown={handleDrillDown}
/>
)}
</div>
)
}

View File

@@ -158,7 +158,7 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{
id: 'byok',
label: 'BYOK',

View File

@@ -5,9 +5,8 @@ import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/core/utils/cn'
/**
* Custom switch component with thin track design.
* Track: 28px width, 6px height, 20px border-radius
* Thumb: 14px diameter circle that overlaps the track
* Switch component styled to match Sim's design system.
* Uses brand color for checked state, neutral border for unchecked.
*/
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -16,21 +15,13 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
disabled={disabled}
className={cn(
'peer inline-flex h-[17px] w-[30px] shrink-0 cursor-pointer items-center rounded-[20px] transition-colors focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'bg-[var(--border-1)] data-[state=checked]:bg-[var(--text-primary)]',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--brand-tertiary-2)] data-[disabled]:opacity-50',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-[14px] w-[14px] rounded-full shadow-sm ring-0 transition-transform',
'bg-[var(--white)]',
'data-[state=checked]:translate-x-[14px] data-[state=unchecked]:translate-x-[2px]'
)}
/>
<SwitchPrimitives.Thumb className='pointer-events-none block h-4 w-4 rounded-full bg-[var(--white)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
</SwitchPrimitives.Root>
))

View File

@@ -291,7 +291,7 @@
},
"packages/ts-sdk": {
"name": "simstudio-ts-sdk",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"node-fetch": "^3.3.2",
},