mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-07 05:05:15 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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を取得
|
||||
|
||||
@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
|
||||
</div>
|
||||
|
||||
1. ワークスペース設定に移動します
|
||||
2. **デプロイ済みMCP**セクションに移動します
|
||||
2. **MCP サーバー**セクションに移動します
|
||||
3. **MCPサーバーを追加**をクリックします
|
||||
4. サーバー設定の詳細を入力します
|
||||
5. 設定を保存します
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
|
||||
</div>
|
||||
|
||||
1. 进入您的工作区设置
|
||||
2. 前往 **Deployed MCPs** 部分
|
||||
2. 前往 **MCP Servers** 部分
|
||||
3. 点击 **Add MCP Server**
|
||||
4. 输入服务器配置信息
|
||||
5. 保存配置
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewContextMenu } from './preview-context-menu'
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewEditor } from './preview-editor'
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewBlock } from './block'
|
||||
@@ -0,0 +1 @@
|
||||
export { PreviewSubflow } from './subflow'
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user