v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes

This commit is contained in:
Waleed
2025-12-31 18:00:04 -08:00
committed by GitHub
58 changed files with 3376 additions and 2107 deletions

View File

@@ -0,0 +1,63 @@
---
title: Tastaturkürzel
description: Meistern Sie die Workflow-Arbeitsfläche mit Tastaturkürzeln und Maussteuerung
---
import { Callout } from 'fumadocs-ui/components/callout'
Beschleunigen Sie die Erstellung Ihrer Workflows mit diesen Tastaturkürzeln und Maussteuerungen. Alle Tastenkombinationen funktionieren, wenn die Arbeitsfläche fokussiert ist (nicht beim Tippen in einem Eingabefeld).
<Callout type="info">
**Mod** bezieht sich auf `Cmd` unter macOS und `Ctrl` unter Windows/Linux.
</Callout>
## Arbeitsflächen-Steuerung
### Maussteuerung
| Aktion | Steuerung |
|--------|---------|
| Arbeitsfläche verschieben | Linksziehen auf leerer Fläche |
| Arbeitsfläche verschieben | Scrollen oder Trackpad |
| Mehrere Blöcke auswählen | Rechtsziehen zum Aufziehen eines Auswahlrahmens |
| Block ziehen | Linksziehen auf Block-Kopfzeile |
| Zur Auswahl hinzufügen | `Mod` + Klick auf Blöcke |
### Workflow-Aktionen
| Tastenkombination | Aktion |
|----------|--------|
| `Mod` + `Enter` | Workflow ausführen (oder abbrechen, falls aktiv) |
| `Mod` + `Z` | Rückgängig |
| `Mod` + `Shift` + `Z` | Wiederholen |
| `Mod` + `C` | Ausgewählte Blöcke kopieren |
| `Mod` + `V` | Blöcke einfügen |
| `Delete` oder `Backspace` | Ausgewählte Blöcke oder Verbindungen löschen |
| `Shift` + `L` | Arbeitsfläche automatisch anordnen |
## Panel-Navigation
Diese Tastenkombinationen wechseln zwischen den Panel-Tabs auf der rechten Seite der Arbeitsfläche.
| Tastenkombination | Aktion |
|----------|--------|
| `C` | Copilot-Tab fokussieren |
| `T` | Toolbar-Tab fokussieren |
| `E` | Editor-Tab fokussieren |
| `Mod` + `F` | Toolbar-Suche fokussieren |
## Globale Navigation
| Tastenkombination | Aktion |
|----------|--------|
| `Mod` + `K` | Suche öffnen |
| `Mod` + `Shift` + `A` | Neuen Agenten-Workflow hinzufügen |
| `Mod` + `Y` | Zu Vorlagen gehen |
| `Mod` + `L` | Zu Logs gehen |
## Dienstprogramm
| Tastenkombination | Aktion |
|----------|--------|
| `Mod` + `D` | Terminal-Konsole leeren |
| `Mod` + `E` | Benachrichtigungen löschen |

View File

@@ -273,7 +273,7 @@ Eine neue Organisation in Jira Service Management erstellen
| `name` | string | Name der erstellten Organisation |
| `success` | boolean | Ob die Operation erfolgreich war |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
Eine Organisation zu einem Service Desk in Jira Service Management hinzufügen

View File

@@ -0,0 +1,64 @@
---
title: Keyboard Shortcuts
description: Master the workflow canvas with keyboard shortcuts and mouse controls
---
import { Callout } from 'fumadocs-ui/components/callout'
Speed up your workflow building with these keyboard shortcuts and mouse controls. All shortcuts work when the canvas is focused (not when typing in an input field).
<Callout type="info">
**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux.
</Callout>
## Canvas Controls
### Mouse Controls
| Action | Control |
|--------|---------|
| Pan/move canvas | Left-drag on empty space |
| Pan/move canvas | Scroll or trackpad |
| Select multiple blocks | Right-drag to draw selection box |
| Drag block | Left-drag on block header |
| Add to selection | `Mod` + click on blocks |
### Workflow Actions
| Shortcut | Action |
|----------|--------|
| `Mod` + `Enter` | Run workflow (or cancel if running) |
| `Mod` + `Z` | Undo |
| `Mod` + `Shift` + `Z` | Redo |
| `Mod` + `C` | Copy selected blocks |
| `Mod` + `V` | Paste blocks |
| `Delete` or `Backspace` | Delete selected blocks or edges |
| `Shift` + `L` | Auto-layout canvas |
## Panel Navigation
These shortcuts switch between panel tabs on the right side of the canvas.
| Shortcut | Action |
|----------|--------|
| `C` | Focus Copilot tab |
| `T` | Focus Toolbar tab |
| `E` | Focus Editor tab |
| `Mod` + `F` | Focus Toolbar search |
## Global Navigation
| Shortcut | Action |
|----------|--------|
| `Mod` + `K` | Open search |
| `Mod` + `Shift` + `A` | Add new agent workflow |
| `Mod` + `Y` | Go to templates |
| `Mod` + `L` | Go to logs |
## Utility
| Shortcut | Action |
|----------|--------|
| `Mod` + `D` | Clear terminal console |
| `Mod` + `E` | Clear notifications |

View File

@@ -14,7 +14,8 @@
"execution",
"permissions",
"sdks",
"self-hosting"
"self-hosting",
"./keyboard-shortcuts/index"
],
"defaultOpen": false
}

View File

@@ -275,7 +275,7 @@ Create a new organization in Jira Service Management
| `name` | string | Name of the created organization |
| `success` | boolean | Whether the operation succeeded |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
Add an organization to a service desk in Jira Service Management

View File

@@ -0,0 +1,64 @@
---
title: Atajos de teclado
description: Domina el lienzo de flujo de trabajo con atajos de teclado y
controles del ratón
---
import { Callout } from 'fumadocs-ui/components/callout'
Acelera la creación de tus flujos de trabajo con estos atajos de teclado y controles del ratón. Todos los atajos funcionan cuando el lienzo está enfocado (no cuando estás escribiendo en un campo de entrada).
<Callout type="info">
**Mod** se refiere a `Cmd` en macOS y `Ctrl` en Windows/Linux.
</Callout>
## Controles del lienzo
### Controles del ratón
| Acción | Control |
|--------|---------|
| Desplazar/mover lienzo | Arrastrar con botón izquierdo en espacio vacío |
| Desplazar/mover lienzo | Desplazamiento o trackpad |
| Seleccionar múltiples bloques | Arrastrar con botón derecho para dibujar cuadro de selección |
| Arrastrar bloque | Arrastrar con botón izquierdo en encabezado del bloque |
| Añadir a la selección | `Mod` + clic en bloques |
### Acciones de flujo de trabajo
| Atajo | Acción |
|----------|--------|
| `Mod` + `Enter` | Ejecutar flujo de trabajo (o cancelar si está en ejecución) |
| `Mod` + `Z` | Deshacer |
| `Mod` + `Shift` + `Z` | Rehacer |
| `Mod` + `C` | Copiar bloques seleccionados |
| `Mod` + `V` | Pegar bloques |
| `Delete` o `Backspace` | Eliminar bloques o conexiones seleccionados |
| `Shift` + `L` | Diseño automático del lienzo |
## Navegación de paneles
Estos atajos cambian entre las pestañas del panel en el lado derecho del lienzo.
| Atajo | Acción |
|----------|--------|
| `C` | Enfocar pestaña Copilot |
| `T` | Enfocar pestaña Barra de herramientas |
| `E` | Enfocar pestaña Editor |
| `Mod` + `F` | Enfocar búsqueda de Barra de herramientas |
## Navegación global
| Atajo | Acción |
|----------|--------|
| `Mod` + `K` | Abrir búsqueda |
| `Mod` + `Shift` + `A` | Añadir nuevo flujo de trabajo de agente |
| `Mod` + `Y` | Ir a plantillas |
| `Mod` + `L` | Ir a registros |
## Utilidad
| Atajo | Acción |
|----------|--------|
| `Mod` + `D` | Limpiar consola del terminal |
| `Mod` + `E` | Limpiar notificaciones |

View File

@@ -273,7 +273,7 @@ Crear una nueva organización en Jira Service Management
| `name` | string | Nombre de la organización creada |
| `success` | boolean | Si la operación tuvo éxito |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
Añadir una organización a un service desk en Jira Service Management

View File

@@ -0,0 +1,64 @@
---
title: Raccourcis clavier
description: Maîtrisez le canevas de workflow avec les raccourcis clavier et les
contrôles de souris
---
import { Callout } from 'fumadocs-ui/components/callout'
Accélérez la création de vos workflows avec ces raccourcis clavier et contrôles de souris. Tous les raccourcis fonctionnent lorsque le canevas est actif (pas lors de la saisie dans un champ de texte).
<Callout type="info">
**Mod** fait référence à `Cmd` sur macOS et `Ctrl` sur Windows/Linux.
</Callout>
## Contrôles du canevas
### Contrôles de la souris
| Action | Contrôle |
|--------|---------|
| Déplacer le canevas | Glisser-gauche sur un espace vide |
| Déplacer le canevas | Molette ou trackpad |
| Sélectionner plusieurs blocs | Glisser-droit pour tracer une zone de sélection |
| Déplacer un bloc | Glisser-gauche sur l'en-tête du bloc |
| Ajouter à la sélection | `Mod` + clic sur les blocs |
### Actions de workflow
| Raccourci | Action |
|----------|--------|
| `Mod` + `Enter` | Exécuter le workflow (ou annuler si en cours) |
| `Mod` + `Z` | Annuler |
| `Mod` + `Shift` + `Z` | Rétablir |
| `Mod` + `C` | Copier les blocs sélectionnés |
| `Mod` + `V` | Coller les blocs |
| `Delete` ou `Backspace` | Supprimer les blocs ou connexions sélectionnés |
| `Shift` + `L` | Disposition automatique du canevas |
## Navigation dans les panneaux
Ces raccourcis permettent de basculer entre les onglets du panneau sur le côté droit du canevas.
| Raccourci | Action |
|----------|--------|
| `C` | Activer l'onglet Copilot |
| `T` | Activer l'onglet Barre d'outils |
| `E` | Activer l'onglet Éditeur |
| `Mod` + `F` | Activer la recherche dans la barre d'outils |
## Navigation globale
| Raccourci | Action |
|----------|--------|
| `Mod` + `K` | Ouvrir la recherche |
| `Mod` + `Shift` + `A` | Ajouter un nouveau workflow d'agent |
| `Mod` + `Y` | Aller aux modèles |
| `Mod` + `L` | Aller aux journaux |
## Utilitaire
| Raccourci | Action |
|----------|--------|
| `Mod` + `D` | Effacer la console du terminal |
| `Mod` + `E` | Effacer les notifications |

View File

@@ -273,7 +273,7 @@ Créer une nouvelle organisation dans Jira Service Management
| `name` | string | Nom de l'organisation créée |
| `success` | boolean | Indique si l'opération a réussi |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
Ajouter une organisation à un service desk dans Jira Service Management

View File

@@ -0,0 +1,63 @@
---
title: キーボードショートカット
description: キーボードショートカットとマウス操作でワークフローキャンバスをマスターしましょう
---
import { Callout } from 'fumadocs-ui/components/callout'
これらのキーボードショートカットとマウス操作でワークフロー構築を高速化できます。すべてのショートカットは、キャンバスにフォーカスがある時に機能します(入力フィールドに入力中は機能しません)。
<Callout type="info">
**Mod**はmacOSでは`Cmd`、Windows/Linuxでは`Ctrl`を指します。
</Callout>
## キャンバス操作
### マウス操作
| 操作 | 操作方法 |
|--------|---------|
| キャンバスの移動 | 空白部分を左ドラッグ |
| キャンバスの移動 | スクロールまたはトラックパッド |
| 複数ブロックの選択 | 右ドラッグで選択ボックスを描画 |
| ブロックのドラッグ | ブロックヘッダーを左ドラッグ |
| 選択に追加 | `Mod` + ブロックをクリック |
### ワークフロー操作
| ショートカット | 操作 |
|----------|--------|
| `Mod` + `Enter` | ワークフローを実行(実行中の場合はキャンセル) |
| `Mod` + `Z` | 元に戻す |
| `Mod` + `Shift` + `Z` | やり直す |
| `Mod` + `C` | 選択したブロックをコピー |
| `Mod` + `V` | ブロックを貼り付け |
| `Delete`または`Backspace` | 選択したブロックまたはエッジを削除 |
| `Shift` + `L` | キャンバスを自動レイアウト |
## パネルナビゲーション
これらのショートカットは、キャンバス右側のパネルタブを切り替えます。
| ショートカット | 操作 |
|----------|--------|
| `C` | Copilotタブにフォーカス |
| `T` | Toolbarタブにフォーカス |
| `E` | Editorタブにフォーカス |
| `Mod` + `F` | Toolbar検索にフォーカス |
## グローバルナビゲーション
| ショートカット | 操作 |
|----------|--------|
| `Mod` + `K` | 検索を開く |
| `Mod` + `Shift` + `A` | 新しいエージェントワークフローを追加 |
| `Mod` + `Y` | テンプレートに移動 |
| `Mod` + `L` | ログに移動 |
## ユーティリティ
| ショートカット | アクション |
|----------|--------|
| `Mod` + `D` | ターミナルコンソールをクリア |
| `Mod` + `E` | 通知をクリア |

View File

@@ -273,7 +273,7 @@ Jira Service Managementで新しい組織を作成する
| `name` | string | 作成された組織の名前 |
| `success` | boolean | 操作が成功したかどうか |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
Jira Service Managementのサービスデスクに組織を追加する

View File

@@ -0,0 +1,63 @@
---
title: 键盘快捷键
description: 通过键盘快捷键和鼠标操作,掌控工作流画布
---
import { Callout } from 'fumadocs-ui/components/callout'
使用这些键盘快捷键和鼠标操作,可以加快你的工作流构建速度。所有快捷键仅在画布聚焦时有效(在输入框中输入时无效)。
<Callout type="info">
**Mod** 指的是在 macOS 上为 `Cmd`,在 Windows/Linux 上为 `Ctrl`。
</Callout>
## 画布操作
### 鼠标操作
| 操作 | 控制方式 |
|--------|---------|
| 平移/移动画布 | 在空白处左键拖动 |
| 平移/移动画布 | 滚轮或触控板 |
| 多选区块 | 右键拖动绘制选择框 |
| 拖动区块 | 在区块标题处左键拖动 |
| 添加到选择 | `Mod` + 点击区块 |
### 工作流操作
| 快捷键 | 操作 |
|----------|--------|
| `Mod` + `Enter` | 运行工作流(如正在运行则取消) |
| `Mod` + `Z` | 撤销 |
| `Mod` + `Shift` + `Z` | 重做 |
| `Mod` + `C` | 复制所选区块 |
| `Mod` + `V` | 粘贴区块 |
| `Delete` 或 `Backspace` | 删除所选区块或连线 |
| `Shift` + `L` | 自动布局画布 |
## 面板导航
这些快捷键可在画布右侧的面板标签页之间切换。
| 快捷键 | 操作 |
|----------|--------|
| `C` | 聚焦 Copilot 标签页 |
| `T` | 聚焦 Toolbar 标签页 |
| `E` | 聚焦 Editor 标签页 |
| `Mod` + `F` | 聚焦 Toolbar 搜索 |
## 全局导航
| 快捷键 | 操作 |
|----------|--------|
| `Mod` + `K` | 打开搜索 |
| `Mod` + `Shift` + `A` | 新建 Agent 工作流 |
| `Mod` + `Y` | 跳转到模板 |
| `Mod` + `L` | 跳转到日志 |
## 实用工具
| 快捷键 | 操作 |
|----------|--------|
| `Mod` + `D` | 清除终端控制台 |
| `Mod` + `E` | 清除通知 |

View File

@@ -273,7 +273,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| `name` | string | 已创建组织的名称 |
| `success` | boolean | 操作是否成功 |
### `jsm_add_organization_to_service_desk`
### `jsm_add_organization`
在 Jira Service Management 中将组织添加到服务台

View File

@@ -50077,7 +50077,7 @@ checksums:
content/68: b7afc8fa3b22ea9327e336f50b82a27c
content/69: bcadfc362b69078beee0088e5936c98b
content/70: 0337e5d7f0bad113be176419350a41b6
content/71: ef61f2bab8cfd25a5228d9df3ff6cf3c
content/71: bb403ace5373d843beffe220c9a8d618
content/72: 35a991daf9336e6bba2bd8818dd66594
content/73: 371d0e46b4bd2c23f559b8bc112f6955
content/74: 13644af4a2d5aea5061e9945e91f5a4f
@@ -50166,3 +50166,21 @@ checksums:
content/27: 764eb0e5d025b68f772d45adb7608349
content/28: 47eb215a0fc230dc651b7bc05ab25ed0
content/29: bf5c6bf1e75c5c5e3a0a5dd1314cb41e
ed03212dda9fce53ddf623d1c4587006:
meta/title: ef00d7494b69def6841620bd6554d040
meta/description: 4b66a56c6ccc3c7e630dfc45eb8bfdf8
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
content/1: 0628b1e7f70de9f2b5dff99452111de9
content/2: fa4a0821069063d96727598f379fb619
content/3: a3825edbe4c255e7370624d27b734399
content/4: 5be2f96951187cdbf39ed7d879322cef
content/5: 4940f2e763be1990113195e4667ff49a
content/6: 27c579ade1a1be3e514d880388c58c2b
content/7: 125beef2eb1e60a492faa9dc03fca0b4
content/8: 62d5214cb3e3ec863bd5b6d74e0df126
content/9: 421b088722ccb029a93a2388cf47d9b3
content/10: e9ddc04f492fea4fb96bfd7fcd3eb84a
content/11: be8e3a9794f70b9c03373db88ffc43ce
content/12: 3a322eee25c8bd5d81e7ae92f4239300
content/13: a82eb7d47a82c3289a00ccf27a860685
content/14: 26b9713de1a21d662c198154b673fd7d

View File

@@ -1049,7 +1049,7 @@ export function Chat() {
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
title='Attach file'
className={cn(
'!bg-transparent cursor-pointer rounded-[6px] p-[0px]',
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}

View File

@@ -0,0 +1,179 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
* Context menu for workflow block(s).
* Displays block-specific actions in a popover at right-click position.
* Supports multi-selection - actions apply to all selected blocks.
*/
export function BlockContextMenu({
isOpen,
position,
menuRef,
onClose,
selectedBlocks,
onCopy,
onPaste,
onDuplicate,
onDelete,
onToggleEnabled,
onToggleHandles,
onRemoveFromSubflow,
onOpenEditor,
onRename,
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
}: BlockContextMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some(
(b) => b.type === 'starter' || b.type === 'start_trigger'
)
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
if (allDisabled) return 'Enable'
return 'Toggle Enabled'
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<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}>
{/* Copy */}
<PopoverItem
className='group'
onClick={() => {
onCopy()
onClose()
}}
>
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Duplicate - hide for starter blocks */}
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onDuplicate()
onClose()
}}
>
Duplicate
</PopoverItem>
)}
{/* Delete */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
{/* Enable/Disable - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onToggleEnabled()
onClose()
}}
>
{getToggleEnabledLabel()}
</PopoverItem>
)}
{/* Flip Handles - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onToggleHandles()
onClose()
}}
>
Flip Handles
</PopoverItem>
)}
{/* Remove from Subflow - only show when applicable */}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onRemoveFromSubflow()
onClose()
}}
>
Remove from Subflow
</PopoverItem>
)}
{/* Rename - only for single block, not subflows */}
{isSingleBlock && !isSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{/* Open Editor - only for single block */}
{isSingleBlock && (
<PopoverItem
onClick={() => {
onOpenEditor()
onClose()
}}
>
Open Editor
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,8 @@
export { BlockContextMenu } from './block-context-menu'
export { PaneContextMenu } from './pane-context-menu'
export type {
BlockContextMenuProps,
ContextMenuBlockInfo,
ContextMenuPosition,
PaneContextMenuProps,
} from './types'

View File

@@ -0,0 +1,152 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
* Context menu for workflow canvas pane.
* Displays canvas-level actions when right-clicking empty space.
*/
export function PaneContextMenu({
isOpen,
position,
menuRef,
onClose,
onUndo,
onRedo,
onPaste,
onAddBlock,
onAutoLayout,
onOpenLogs,
onOpenVariables,
onOpenChat,
onInvite,
hasClipboard = false,
disableEdit = false,
disableAdmin = false,
canUndo = false,
canRedo = false,
}: PaneContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<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}>
{/* Undo */}
<PopoverItem
className='group'
disabled={disableEdit || !canUndo}
onClick={() => {
onUndo()
onClose()
}}
>
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Redo */}
<PopoverItem
className='group'
disabled={disableEdit || !canRedo}
onClick={() => {
onRedo()
onClose()
}}
>
<span>Redo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Add Block */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onAddBlock()
onClose()
}}
>
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
</PopoverItem>
{/* Auto-layout */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onAutoLayout()
onClose()
}}
>
<span>Auto-layout</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Logs */}
<PopoverItem
className='group'
onClick={() => {
onOpenLogs()
onClose()
}}
>
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Variables */}
<PopoverItem
onClick={() => {
onOpenVariables()
onClose()
}}
>
Variables
</PopoverItem>
{/* Open Chat */}
<PopoverItem
onClick={() => {
onOpenChat()
onClose()
}}
>
Open Chat
</PopoverItem>
{/* Invite to Workspace - admin only */}
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,93 @@
import type { RefObject } from 'react'
/**
* Position for context menu placement
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Block information passed to context menu for action handling
*/
export interface ContextMenuBlockInfo {
/** Block ID */
id: string
/** Block type (e.g., 'agent', 'function', 'loop') */
type: string
/** Whether block is enabled */
enabled: boolean
/** Whether block uses horizontal handles */
horizontalHandles: boolean
/** Parent subflow ID if nested in loop/parallel */
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
}
/**
* Props for BlockContextMenu component
*/
export interface BlockContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element (for click-outside detection) */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Selected block(s) info */
selectedBlocks: ContextMenuBlockInfo[]
/** Callbacks for menu actions */
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */
showRemoveFromSubflow?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
}
/**
* Props for PaneContextMenu component
*/
export interface PaneContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Callbacks for menu actions */
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onOpenLogs: () => void
onOpenVariables: () => void
onOpenChat: () => void
onInvite: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
/** Whether admin actions are disabled (no admin permission) */
disableAdmin?: boolean
/** Whether undo is available */
canUndo?: boolean
/** Whether redo is available */
canRedo?: boolean
}

View File

@@ -42,7 +42,13 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin
* @returns Editor panel content
*/
export function Editor() {
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed } = usePanelEditorStore()
const {
currentBlockId,
connectionsHeight,
toggleConnectionsCollapsed,
shouldFocusRename,
setShouldFocusRename,
} = usePanelEditorStore()
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
@@ -158,6 +164,14 @@ export function Editor() {
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock && !isSubflow) {
handleStartRename()
setShouldFocusRename(false)
}
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
/**
* Handles opening documentation link in a new secure tab.
*/

View File

@@ -61,7 +61,7 @@ export interface SubflowNodeData {
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(null)
const currentWorkflow = useCurrentWorkflow()
@@ -184,7 +184,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
variant='ghost'
onClick={(e) => {
e.stopPropagation()
collaborativeRemoveBlock(id)
collaborativeBatchRemoveBlocks([id])
}}
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
>

View File

@@ -4,8 +4,13 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
/**
* Props for the ActionBar component
*/
@@ -27,11 +32,39 @@ interface ActionBarProps {
export const ActionBar = memo(
function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
const {
collaborativeRemoveBlock,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeToggleBlockEnabled,
collaborativeDuplicateBlock,
collaborativeToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const blocks = useWorkflowStore((state) => state.blocks)
const subBlockStore = useSubBlockStore()
const handleDuplicateBlock = useCallback(() => {
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
newId,
newName,
positionOffset: DEFAULT_DUPLICATE_OFFSET,
subBlockValues,
})
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [
blockId,
blocks,
activeWorkflowId,
subBlockStore.workflowValues,
collaborativeBatchAddBlocks,
])
/**
* Optimized single store subscription for all block data
@@ -115,7 +148,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeDuplicateBlock(blockId)
handleDuplicateBlock()
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
@@ -185,7 +218,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeRemoveBlock(blockId)
collaborativeBatchRemoveBlocks([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Node } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ContextMenuBlockInfo, ContextMenuPosition } from '../components/context-menu/types'
type MenuType = 'block' | 'pane' | null
interface UseCanvasContextMenuProps {
/** Current blocks from workflow store */
blocks: Record<string, BlockState>
/** Function to get nodes from ReactFlow */
getNodes: () => Node[]
}
/**
* Hook for managing workflow canvas context menus.
*
* Handles:
* - Right-click event handling for blocks and pane
* - Menu open/close state for both menu types
* - Click-outside detection to close menus
* - Selected block info extraction for multi-selection support
*/
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<ContextMenuBlockInfo[]>([])
const menuRef = useRef<HTMLDivElement>(null)
/** Converts nodes to block info for context menu */
const nodesToBlockInfos = useCallback(
(nodes: Node[]): ContextMenuBlockInfo[] =>
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
return {
id: n.id,
type: block?.type || '',
enabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
}
}),
[blocks]
)
/**
* Handle right-click on a node (block)
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
setActiveMenu('block')
},
[getNodes, nodesToBlockInfos]
)
/**
* Handle right-click on the pane (empty canvas area)
*/
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks([])
setActiveMenu('pane')
}, [])
/**
* Handle right-click on a selection (multiple selected nodes)
*/
const handleSelectionContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(selectedNodes))
setActiveMenu('block')
},
[getNodes, nodesToBlockInfos]
)
/**
* Close the active context menu
*/
const closeMenu = useCallback(() => {
setActiveMenu(null)
}, [])
/**
* Handle clicks outside the menu to close it
*/
useEffect(() => {
if (!activeMenu) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as globalThis.Node)) {
closeMenu()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('click', handleClickOutside)
}
}, [activeMenu, closeMenu])
/**
* Close menu on scroll or zoom to prevent menu from being positioned incorrectly
*/
useEffect(() => {
if (!activeMenu) return
const handleScroll = () => closeMenu()
window.addEventListener('wheel', handleScroll, { passive: true })
return () => {
window.removeEventListener('wheel', handleScroll)
}
}, [activeMenu, closeMenu])
return {
/** Whether the block context menu is open */
isBlockMenuOpen: activeMenu === 'block',
/** Whether the pane context menu is open */
isPaneMenuOpen: activeMenu === 'pane',
/** Position for the context menu */
position,
/** Ref for the menu element */
menuRef,
/** Selected blocks info for multi-selection actions */
selectedBlocks,
/** Handler for ReactFlow onNodeContextMenu */
handleNodeContextMenu,
/** Handler for ReactFlow onPaneContextMenu */
handlePaneContextMenu,
/** Handler for ReactFlow onSelectionContextMenu */
handleSelectionContextMenu,
/** Close the active context menu */
closeMenu,
}
}

View File

@@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<main className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<main className='flex h-full flex-1 flex-col overflow-hidden'>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
)

View File

@@ -16,6 +16,7 @@ import ReactFlow, {
import 'reactflow/dist/style.css'
import { createLogger } from '@sim/logger'
import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import type { OAuthProvider } from '@/lib/oauth'
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
@@ -30,6 +31,10 @@ import {
SubflowNodeComponent,
Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import {
BlockContextMenu,
PaneContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
@@ -42,6 +47,7 @@ import {
useCurrentWorkflow,
useNodeUtilities,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
import {
clampPositionToContainer,
estimateBlockDimensions,
@@ -52,15 +58,19 @@ import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { usePanelEditorStore } from '@/stores/panel/editor/store'
import { useSearchModalStore } from '@/stores/search-modal/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName } from '@/stores/workflows/utils'
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Lazy-loaded components for non-critical UI that can load after initial render */
@@ -77,6 +87,59 @@ const LazyOAuthRequiredModal = lazy(() =>
const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/**
* Calculates the offset to paste blocks at viewport center
*/
function calculatePasteOffset(
clipboard: {
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
} | null,
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET
const clipboardBlocks = Object.values(clipboard.blocks)
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => {
const width =
b.type === 'loop' || b.type === 'parallel'
? CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
return b.position.x + width
})
)
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
const maxY = Math.max(
...clipboardBlocks.map((b) => {
const height =
b.type === 'loop' || b.type === 'parallel'
? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
: Math.max(b.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
return b.position.y + height
})
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) return DEFAULT_PASTE_OFFSET
const rect = flowContainer.getBoundingClientRect()
const viewportCenter = screenToFlowPosition({
x: rect.width / 2,
y: rect.height / 2,
})
return {
x: viewportCenter.x - clipboardCenter.x,
y: viewportCenter.y - clipboardCenter.y,
}
}
/** Custom node types for ReactFlow. */
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
@@ -93,20 +156,14 @@ const edgeTypes: EdgeTypes = {
/** ReactFlow configuration constants. */
const defaultEdgeOptions = { type: 'custom' }
/** Tailwind classes for ReactFlow internal element styling */
const reactFlowStyles = [
// Z-index layering
'[&_.react-flow__edges]:!z-0',
'[&_.react-flow__node]:!z-[21]',
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[60]',
// Light mode: transparent pane to show dots
'[&_.react-flow__pane]:!bg-transparent',
'[&_.react-flow__renderer]:!bg-transparent',
// Dark mode: solid background, hide dots
'dark:[&_.react-flow__pane]:!bg-[var(--bg)]',
'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]',
'dark:[&_.react-flow__background]:hidden',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -132,6 +189,8 @@ const WorkflowContent = React.memo(() => {
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
const [isShiftPressed, setIsShiftPressed] = useState(false)
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const [oauthModal, setOauthModal] = useState<{
provider: OAuthProvider
@@ -143,7 +202,7 @@ const WorkflowContent = React.memo(() => {
const params = useParams()
const router = useRouter()
const { screenToFlowPosition, getNodes, fitView, getIntersectingNodes } = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -151,17 +210,39 @@ const WorkflowContent = React.memo(() => {
const addNotification = useNotificationStore((state) => state.addNotification)
const { workflows, activeWorkflowId, hydration, setActiveWorkflow } = useWorkflowRegistry(
const {
workflows,
activeWorkflowId,
hydration,
setActiveWorkflow,
copyBlocks,
preparePasteData,
hasClipboard,
clipboard,
} = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
hydration: state.hydration,
setActiveWorkflow: state.setActiveWorkflow,
copyBlocks: state.copyBlocks,
preparePasteData: state.preparePasteData,
hasClipboard: state.hasClipboard,
clipboard: state.clipboard,
}))
)
const currentWorkflow = useCurrentWorkflow()
// Undo/redo availability for context menu
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const undoRedoStacks = useUndoRedoStore((s) => s.stacks)
const undoRedoKey = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const undoRedoStack = (undoRedoKey && undoRedoStacks[undoRedoKey]) || { undo: [], redo: [] }
const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
@@ -172,10 +253,8 @@ const WorkflowContent = React.memo(() => {
const copilotCleanup = useCopilotStore((state) => state.cleanup)
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
// Snap to grid settings
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const snapGrid: [number, number] = useMemo(
@@ -183,7 +262,6 @@ const WorkflowContent = React.memo(() => {
[snapToGridSize]
)
// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
@@ -208,7 +286,6 @@ const WorkflowContent = React.memo(() => {
getBlockDimensions,
} = useNodeUtilities(blocks)
/** Triggers immediate subflow resize without delays. */
const resizeLoopNodesWrapper = useCallback(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
@@ -336,16 +413,58 @@ const WorkflowContent = React.memo(() => {
}, [userPermissions, currentWorkflow.isSnapshotView])
const {
collaborativeAddBlock: addBlock,
collaborativeAddEdge: addEdge,
collaborativeRemoveBlock: removeBlock,
collaborativeRemoveEdge: removeEdge,
collaborativeUpdateBlockPosition,
collaborativeBatchUpdatePositions,
collaborativeUpdateParentId: updateParentId,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeToggleBlockEnabled,
collaborativeToggleBlockHandles,
undo,
redo,
} = useCollaborativeWorkflow()
const updateBlockPosition = useCallback(
(id: string, position: { x: number; y: number }) => {
collaborativeBatchUpdatePositions([{ id, position }])
},
[collaborativeBatchUpdatePositions]
)
const addBlock = useCallback(
(
id: string,
type: string,
name: string,
position: { x: number; y: number },
data?: Record<string, unknown>,
parentId?: string,
extent?: 'parent',
autoConnectEdge?: Edge,
triggerMode?: boolean
) => {
const blockData: Record<string, unknown> = { ...(data || {}) }
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
const block = prepareBlockState({
id,
type,
name,
position,
data: blockData,
parentId,
extent,
triggerMode,
})
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks]
)
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
useShallow((state) => ({
activeBlockIds: state.activeBlockIds,
@@ -419,7 +538,7 @@ const WorkflowContent = React.memo(() => {
const result = updateNodeParentUtil(
nodeId,
newParentId,
collaborativeUpdateBlockPosition,
updateBlockPosition,
updateParentId,
() => resizeLoopNodesWrapper()
)
@@ -443,7 +562,7 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
collaborativeUpdateBlockPosition,
updateBlockPosition,
updateParentId,
blocks,
edgesForDisplay,
@@ -467,6 +586,186 @@ const WorkflowContent = React.memo(() => {
return () => clearTimeout(debounceTimer)
}, [handleAutoLayout])
const {
isBlockMenuOpen,
isPaneMenuOpen,
position: contextMenuPosition,
menuRef: contextMenuRef,
selectedBlocks: contextMenuBlocks,
handleNodeContextMenu,
handlePaneContextMenu,
handleSelectionContextMenu,
closeMenu: closeContextMenu,
} = useCanvasContextMenu({ blocks, getNodes })
const handleContextCopy = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
copyBlocks(blockIds)
}, [contextMenuBlocks, copyBlocks])
const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
for (const block of pastedBlocksArray) {
if (TriggerUtils.isAnyTriggerType(block.type)) {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
if (issue) {
const message =
issue.issue === 'legacy'
? 'Cannot paste trigger blocks when a legacy Start block exists.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
}
}
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
hasClipboard,
clipboard,
screenToFlowPosition,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
const handleContextDuplicate = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
copyBlocks(blockIds)
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
for (const block of pastedBlocksArray) {
if (TriggerUtils.isAnyTriggerType(block.type)) {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
if (issue) {
const message =
issue.issue === 'legacy'
? 'Cannot duplicate trigger blocks when a legacy Start block exists.'
: `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
}
}
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
contextMenuBlocks,
copyBlocks,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
collaborativeBatchRemoveBlocks(blockIds)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
const handleContextToggleEnabled = useCallback(() => {
contextMenuBlocks.forEach((block) => {
collaborativeToggleBlockEnabled(block.id)
})
}, [contextMenuBlocks, collaborativeToggleBlockEnabled])
const handleContextToggleHandles = useCallback(() => {
contextMenuBlocks.forEach((block) => {
collaborativeToggleBlockHandles(block.id)
})
}, [contextMenuBlocks, collaborativeToggleBlockHandles])
const handleContextRemoveFromSubflow = useCallback(() => {
contextMenuBlocks.forEach((block) => {
if (block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId: block.id } })
)
}
})
}, [contextMenuBlocks])
const handleContextOpenEditor = useCallback(() => {
if (contextMenuBlocks.length === 1) {
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
}
}, [contextMenuBlocks])
const handleContextRename = useCallback(() => {
if (contextMenuBlocks.length === 1) {
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
usePanelEditorStore.getState().setShouldFocusRename(true)
}
}, [contextMenuBlocks])
const handleContextAddBlock = useCallback(() => {
useSearchModalStore.getState().open()
}, [])
const handleContextOpenLogs = useCallback(() => {
router.push(`/workspace/${workspaceId}/logs?workflowIds=${workflowIdParam}`)
}, [router, workspaceId, workflowIdParam])
const handleContextOpenVariables = useCallback(() => {
useVariablesStore.getState().setIsOpen(true)
}, [])
const handleContextOpenChat = useCallback(() => {
useChatStore.getState().setIsChatOpen(true)
}, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => {
let cleanup: (() => void) | null = null
@@ -495,6 +794,54 @@ const WorkflowContent = React.memo(() => {
) {
event.preventDefault()
redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
const selectedNodes = getNodes().filter((node) => node.selected)
if (selectedNodes.length > 0) {
event.preventDefault()
copyBlocks(selectedNodes.map((node) => node.id))
} else {
const currentBlockId = usePanelEditorStore.getState().currentBlockId
if (currentBlockId && blocks[currentBlockId]) {
event.preventDefault()
copyBlocks([currentBlockId])
}
}
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
if (pasteData) {
const pastedBlocks = Object.values(pasteData.blocks)
for (const block of pastedBlocks) {
if (TriggerUtils.isAnyTriggerType(block.type)) {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
if (issue) {
const message =
issue.issue === 'legacy'
? 'Cannot paste trigger blocks when a legacy Start block exists.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
}
}
collaborativeBatchAddBlocks(
pastedBlocks,
pasteData.edges,
pasteData.loops,
pasteData.parallels,
pasteData.subBlockValues
)
}
}
}
}
@@ -504,7 +851,22 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('keydown', handleKeyDown)
if (cleanup) cleanup()
}
}, [debouncedAutoLayout, undo, redo])
}, [
debouncedAutoLayout,
undo,
redo,
getNodes,
copyBlocks,
preparePasteData,
collaborativeBatchAddBlocks,
hasClipboard,
effectivePermissions.canEdit,
blocks,
addNotification,
activeWorkflowId,
clipboard,
screenToFlowPosition,
])
/**
* Removes all edges connected to a block, skipping individual edge recording for undo/redo.
@@ -617,14 +979,17 @@ const WorkflowContent = React.memo(() => {
/** Creates a standardized edge object for workflow connections. */
const createEdgeObject = useCallback(
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
id: crypto.randomUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}),
(sourceId: string, targetId: string, sourceHandle: string): Edge => {
const edge = {
id: crypto.randomUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
return edge
},
[]
)
@@ -1568,18 +1933,39 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
// Sync derived nodes to display nodes when structure changes
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(true)
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(false)
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
useEffect(() => {
if (isShiftPressed) {
document.body.style.userSelect = 'none'
} else {
document.body.style.userSelect = ''
}
return () => {
document.body.style.userSelect = ''
}
}, [isShiftPressed])
useEffect(() => {
setDisplayNodes(derivedNodes)
}, [derivedNodes])
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
const onNodesChange = useCallback((changes: NodeChange[]) => {
// Apply position changes to local state for smooth rendering
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
// Don't sync to store during drag - that's handled in onNodeDragStop
// Only sync non-position changes (like selection) to store if needed
}, [])
/**
@@ -1673,21 +2059,12 @@ const WorkflowContent = React.memo(() => {
missingParentId: parentId,
})
// Fix the node by removing its parent reference and calculating absolute position
const absolutePosition = getNodeAbsolutePosition(id)
// Update the node to remove parent reference and use absolute position
collaborativeUpdateBlockPosition(id, absolutePosition)
updateBlockPosition(id, absolutePosition)
updateParentId(id, '', 'parent')
}
})
}, [
blocks,
collaborativeUpdateBlockPosition,
updateParentId,
getNodeAbsolutePosition,
isWorkflowReady,
])
}, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady])
/** Handles edge removal changes. */
const onEdgesChange = useCallback(
@@ -2095,9 +2472,7 @@ const WorkflowContent = React.memo(() => {
}
}
// Emit collaborative position update for the final position
// This ensures other users see the smooth final position
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
updateBlockPosition(node.id, finalPosition)
// Record single move entry on drag end to avoid micro-moves
const start = getDragStartPosition()
@@ -2218,7 +2593,7 @@ const WorkflowContent = React.memo(() => {
dragStartParentId,
potentialParentId,
updateNodeParent,
collaborativeUpdateBlockPosition,
updateBlockPosition,
addEdge,
tryCreateAutoConnectEdge,
blocks,
@@ -2232,7 +2607,57 @@ const WorkflowContent = React.memo(() => {
]
)
/** Clears edge selection and panel state when clicking empty canvas. */
// Lock selection mode when selection drag starts (captures Shift state at drag start)
const onSelectionStart = useCallback(() => {
if (isShiftPressed) {
setIsSelectionDragActive(true)
}
}, [isShiftPressed])
const onSelectionEnd = useCallback(() => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
}, [])
const onSelectionDragStop = useCallback(
(_event: React.MouseEvent, nodes: any[]) => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
if (nodes.length === 0) return
const positionUpdates = nodes.map((node) => {
const currentBlock = blocks[node.id]
const currentParentId = currentBlock?.data?.parentId
let finalPosition = node.position
if (currentParentId) {
const parentNode = getNodes().find((n) => n.id === currentParentId)
if (parentNode) {
const containerDimensions = {
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
finalPosition = clampPositionToContainer(
node.position,
containerDimensions,
blockDimensions
)
}
}
return { id: node.id, position: finalPosition }
})
collaborativeBatchUpdatePositions(positionUpdates)
},
[blocks, getNodes, collaborativeBatchUpdatePositions]
)
const onPaneClick = useCallback(() => {
setSelectedEdgeInfo(null)
usePanelEditorStore.getState().clearCurrentBlock()
@@ -2333,17 +2758,23 @@ const WorkflowContent = React.memo(() => {
}
event.preventDefault()
const primaryNode = selectedNodes[0]
removeBlock(primaryNode.id)
const selectedIds = selectedNodes.map((node) => node.id)
collaborativeBatchRemoveBlocks(selectedIds)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEdgeInfo, removeEdge, getNodes, removeBlock, effectivePermissions.canEdit])
}, [
selectedEdgeInfo,
removeEdge,
getNodes,
collaborativeBatchRemoveBlocks,
effectivePermissions.canEdit,
])
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
<div className='flex h-full w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1'>
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
<div
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
@@ -2390,24 +2821,29 @@ const WorkflowContent = React.memo(() => {
proOptions={reactFlowProOptions}
connectionLineStyle={connectionLineStyle}
connectionLineType={ConnectionLineType.SmoothStep}
onNodeClick={(e, _node) => {
e.stopPropagation()
}}
onPaneClick={onPaneClick}
onEdgeClick={onEdgeClick}
onPaneContextMenu={handlePaneContextMenu}
onNodeContextMenu={handleNodeContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectNodesOnDrag={false}
selectionOnDrag={isShiftPressed || isSelectionDragActive}
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
multiSelectionKeyCode={['Meta', 'Control']}
nodesConnectable={effectivePermissions.canEdit}
nodesDraggable={effectivePermissions.canEdit}
draggable={false}
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
@@ -2426,6 +2862,50 @@ const WorkflowContent = React.memo(() => {
</Suspense>
<DiffControls />
{/* Context Menus */}
<BlockContextMenu
isOpen={isBlockMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
selectedBlocks={contextMenuBlocks}
onCopy={handleContextCopy}
onPaste={handleContextPaste}
onDuplicate={handleContextDuplicate}
onDelete={handleContextDelete}
onToggleEnabled={handleContextToggleEnabled}
onToggleHandles={handleContextToggleHandles}
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)}
disableEdit={!effectivePermissions.canEdit}
/>
<PaneContextMenu
isOpen={isPaneMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
onUndo={undo}
onRedo={redo}
onPaste={handleContextPaste}
onAddBlock={handleContextAddBlock}
onAutoLayout={handleAutoLayout}
onOpenLogs={handleContextOpenLogs}
onOpenVariables={handleContextOpenVariables}
onOpenChat={handleContextOpenChat}
onInvite={handleContextInvite}
hasClipboard={hasClipboard()}
disableEdit={!effectivePermissions.canEdit}
disableAdmin={!effectivePermissions.canAdmin}
canUndo={canUndo}
canRedo={canRedo}
/>
</>
)}

View File

@@ -151,6 +151,13 @@ export function WorkspaceHeader({
setIsMounted(true)
}, [])
// Listen for open-invite-modal event from context menu
useEffect(() => {
const handleOpenInvite = () => setIsInviteModalOpen(true)
window.addEventListener('open-invite-modal', handleOpenInvite)
return () => window.removeEventListener('open-invite-modal', handleOpenInvite)
}, [])
/**
* Focus the inline list rename input when it becomes active
*/

View File

@@ -31,7 +31,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
{ label: 'Add Customer', id: 'add_customer' },
{ label: 'Get Organizations', id: 'get_organizations' },
{ label: 'Create Organization', id: 'create_organization' },
{ label: 'Add Organization to Service Desk', id: 'add_organization_to_service_desk' },
{ label: 'Add Organization', id: 'add_organization' },
{ label: 'Get Queues', id: 'get_queues' },
{ label: 'Get SLA', id: 'get_sla' },
{ label: 'Get Transitions', id: 'get_transitions' },
@@ -107,7 +107,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
'get_customers',
'add_customer',
'get_organizations',
'add_organization_to_service_desk',
'add_organization',
'get_queues',
],
},
@@ -270,7 +270,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
type: 'short-input',
required: true,
placeholder: 'Enter organization ID',
condition: { field: 'operation', value: 'add_organization_to_service_desk' },
condition: { field: 'operation', value: 'add_organization' },
},
{
id: 'participantAccountIds',
@@ -332,7 +332,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
'jsm_add_customer',
'jsm_get_organizations',
'jsm_create_organization',
'jsm_add_organization_to_service_desk',
'jsm_add_organization',
'jsm_get_queues',
'jsm_get_sla',
'jsm_get_transitions',
@@ -367,8 +367,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
return 'jsm_get_organizations'
case 'create_organization':
return 'jsm_create_organization'
case 'add_organization_to_service_desk':
return 'jsm_add_organization_to_service_desk'
case 'add_organization':
return 'jsm_add_organization'
case 'get_queues':
return 'jsm_get_queues'
case 'get_sla':
@@ -560,7 +560,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
...baseParams,
name: params.organizationName,
}
case 'add_organization_to_service_desk':
case 'add_organization':
if (!params.serviceDeskId) {
throw new Error('Service Desk ID is required')
}

View File

@@ -2,8 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { useSession } from '@/lib/auth/auth-client'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
@@ -16,9 +14,9 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Position } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
const logger = createLogger('CollaborativeWorkflow')
@@ -201,39 +199,6 @@ export function useCollaborativeWorkflow() {
try {
if (target === 'block') {
switch (operation) {
case 'add':
workflowStore.addBlock(
payload.id,
payload.type,
payload.name,
payload.position,
payload.data,
payload.parentId,
payload.extent,
{
enabled: payload.enabled,
horizontalHandles: payload.horizontalHandles,
advancedMode: payload.advancedMode,
triggerMode: payload.triggerMode ?? false,
height: payload.height,
}
)
if (payload.autoConnectEdge) {
workflowStore.addEdge(payload.autoConnectEdge)
}
// Apply subblock values if present in payload
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) {
return
}
const value = (subblock as any)?.value
if (value !== undefined && value !== null) {
subBlockStore.setValue(payload.id, subblockId, value)
}
})
}
break
case 'update-position': {
const blockId = payload.id
@@ -265,40 +230,6 @@ export function useCollaborativeWorkflow() {
case 'update-name':
workflowStore.updateBlockName(payload.id, payload.name)
break
case 'remove': {
const blockId = payload.id
const blocksToRemove = new Set<string>([blockId])
const findAllDescendants = (parentId: string) => {
Object.entries(workflowStore.blocks).forEach(([id, block]) => {
if (block.data?.parentId === parentId) {
blocksToRemove.add(id)
findAllDescendants(id)
}
})
}
findAllDescendants(blockId)
workflowStore.removeBlock(blockId)
lastPositionTimestamps.current.delete(blockId)
const updatedBlocks = useWorkflowStore.getState().blocks
const updatedEdges = useWorkflowStore.getState().edges
const graph = {
blocksById: updatedBlocks,
edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [workflowId, userId] = key.split(':')
if (workflowId === activeWorkflowId) {
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
}
})
break
}
case 'toggle-enabled':
workflowStore.toggleBlockEnabled(payload.id)
break
@@ -318,40 +249,20 @@ export function useCollaborativeWorkflow() {
}
break
}
case 'duplicate':
workflowStore.addBlock(
payload.id,
payload.type,
payload.name,
payload.position,
payload.data,
payload.parentId,
payload.extent,
{
enabled: payload.enabled,
horizontalHandles: payload.horizontalHandles,
advancedMode: payload.advancedMode,
triggerMode: payload.triggerMode ?? false,
height: payload.height,
}
)
// Handle auto-connect edge if present
if (payload.autoConnectEdge) {
workflowStore.addEdge(payload.autoConnectEdge)
}
// Apply subblock values from duplicate payload so collaborators see content immediately
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) {
return
}
const value = (subblock as any)?.value
if (value !== undefined) {
subBlockStore.setValue(payload.id, subblockId, value)
}
} else if (target === 'blocks') {
switch (operation) {
case 'batch-update-positions': {
const { updates } = payload
if (Array.isArray(updates)) {
updates.forEach(({ id, position }: { id: string; position: Position }) => {
if (id && position) {
workflowStore.updateBlockPosition(id, position)
}
})
}
break
}
}
} else if (target === 'edge') {
switch (operation) {
@@ -439,9 +350,6 @@ export function useCollaborativeWorkflow() {
case 'remove':
variablesStore.deleteVariable(payload.variableId)
break
case 'duplicate':
variablesStore.duplicateVariable(payload.sourceVariableId, payload.id)
break
}
} else if (target === 'workflow') {
switch (operation) {
@@ -477,6 +385,93 @@ export function useCollaborativeWorkflow() {
break
}
}
if (target === 'blocks') {
switch (operation) {
case 'batch-add-blocks': {
const {
blocks,
edges,
loops,
parallels,
subBlockValues: addedSubBlockValues,
} = payload
logger.info('Received batch-add-blocks from remote user', {
userId,
blockCount: (blocks || []).length,
edgeCount: (edges || []).length,
})
;(blocks || []).forEach((block: BlockState) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode ?? false,
height: block.height,
}
)
})
;(edges || []).forEach((edge: Edge) => {
workflowStore.addEdge(edge)
})
if (loops) {
Object.entries(loops as Record<string, Loop>).forEach(([loopId, loopConfig]) => {
useWorkflowStore.setState((state) => ({
loops: { ...state.loops, [loopId]: loopConfig },
}))
})
}
if (parallels) {
Object.entries(parallels as Record<string, Parallel>).forEach(
([parallelId, parallelConfig]) => {
useWorkflowStore.setState((state) => ({
parallels: { ...state.parallels, [parallelId]: parallelConfig },
}))
}
)
}
if (addedSubBlockValues && activeWorkflowId) {
Object.entries(
addedSubBlockValues as Record<string, Record<string, unknown>>
).forEach(([blockId, subBlocks]) => {
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
subBlockStore.setValue(blockId, subBlockId, value)
})
})
}
logger.info('Successfully applied batch-add-blocks from remote user')
break
}
case 'batch-remove-blocks': {
const { ids } = payload
logger.info('Received batch-remove-blocks from remote user', {
userId,
count: (ids || []).length,
})
;(ids || []).forEach((id: string) => {
workflowStore.removeBlock(id)
})
logger.info('Successfully applied batch-remove-blocks from remote user')
break
}
}
}
} catch (error) {
logger.error('Error applying remote operation:', error)
} finally {
@@ -726,294 +721,33 @@ export function useCollaborativeWorkflow() {
]
)
const executeQueuedDebouncedOperation = useCallback(
(operation: string, target: string, payload: any, localAction: () => void) => {
if (isApplyingRemoteChange.current) return
if (isBaselineDiffView) {
logger.debug('Skipping debounced socket operation while viewing baseline diff:', operation)
return
}
const collaborativeBatchUpdatePositions = useCallback(
(updates: Array<{ id: string; position: Position }>) => {
if (!isInActiveRoom()) {
logger.debug('Skipping debounced operation - not in active workflow', {
currentWorkflowId,
activeWorkflowId,
operation,
target,
})
logger.debug('Skipping batch position update - not in active workflow')
return
}
localAction()
if (updates.length === 0) return
emitWorkflowOperation(operation, target, payload)
},
[emitWorkflowOperation, isBaselineDiffView, isInActiveRoom, currentWorkflowId, activeWorkflowId]
)
const collaborativeAddBlock = useCallback(
(
id: string,
type: string,
name: string,
position: Position,
data?: Record<string, any>,
parentId?: string,
extent?: 'parent',
autoConnectEdge?: Edge,
triggerMode?: boolean
) => {
// Skip socket operations when viewing baseline diff
if (isBaselineDiffView) {
logger.debug('Skipping collaborative add block while viewing baseline diff')
return
}
if (!isInActiveRoom()) {
logger.debug('Skipping collaborative add block - not in active workflow', {
currentWorkflowId,
activeWorkflowId,
})
return
}
const blockConfig = getBlock(type)
// Handle loop/parallel blocks that don't use BlockConfig
if (!blockConfig && (type === 'loop' || type === 'parallel')) {
// For loop/parallel blocks, use empty subBlocks and outputs
const completeBlockData = {
id,
type,
name,
position,
data: data || {},
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: triggerMode || false,
height: 0,
parentId,
extent,
autoConnectEdge, // Include edge data for atomic operation
}
// Skip if applying remote changes (don't auto-select blocks added by other users)
if (isApplyingRemoteChange.current) {
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
triggerMode: triggerMode || false,
})
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
return
}
// Generate operation ID for queue tracking
const operationId = crypto.randomUUID()
// Add to queue for retry mechanism
addToQueue({
id: operationId,
operation: {
operation: 'add',
target: 'block',
payload: completeBlockData,
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
// Apply locally first (immediate UI feedback)
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
triggerMode: triggerMode || false,
})
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
undoRedo.recordAddBlock(id, autoConnectEdge)
// Automatically select the newly added block (opens editor tab)
usePanelEditorStore.getState().setCurrentBlockId(id)
return
}
if (!blockConfig) {
logger.error(`Block type ${type} not found`)
return
}
// Generate subBlocks and outputs from the block configuration
const subBlocks: Record<string, any> = {}
if (blockConfig.subBlocks) {
blockConfig.subBlocks.forEach((subBlock) => {
let initialValue: unknown = null
if (typeof subBlock.value === 'function') {
try {
initialValue = subBlock.value({})
} catch (error) {
logger.warn('Failed to resolve dynamic sub-block default value', {
subBlockId: subBlock.id,
error: error instanceof Error ? error.message : String(error),
})
}
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format') {
initialValue = [
{
id: crypto.randomUUID(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
} else if (subBlock.type === 'table') {
initialValue = []
}
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: initialValue,
}
})
}
// Get outputs based on trigger mode
const isTriggerMode = triggerMode || false
const outputs = getBlockOutputs(type, subBlocks, isTriggerMode)
const completeBlockData = {
id,
type,
name,
position,
data: data || {},
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: isTriggerMode,
height: 0, // Default height, will be set by the UI
parentId,
extent,
autoConnectEdge, // Include edge data for atomic operation
}
// Skip if applying remote changes (don't auto-select blocks added by other users)
if (isApplyingRemoteChange.current) return
// Generate operation ID
const operationId = crypto.randomUUID()
// Add to queue
addToQueue({
id: operationId,
operation: {
operation: 'add',
target: 'block',
payload: completeBlockData,
operation: 'batch-update-positions',
target: 'blocks',
payload: { updates },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
// Apply locally
workflowStore.addBlock(id, type, name, position, data, parentId, extent, {
triggerMode: triggerMode || false,
})
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
undoRedo.recordAddBlock(id, autoConnectEdge)
// Automatically select the newly added block (opens editor tab)
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[
workflowStore,
activeWorkflowId,
addToQueue,
session?.user?.id,
isBaselineDiffView,
isInActiveRoom,
currentWorkflowId,
undoRedo,
]
)
const collaborativeRemoveBlock = useCallback(
(id: string) => {
cancelOperationsForBlock(id)
// Get all blocks that will be removed (including nested blocks in subflows)
const blocksToRemove = new Set<string>([id])
const findAllDescendants = (parentId: string) => {
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
if (block.data?.parentId === parentId) {
blocksToRemove.add(blockId)
findAllDescendants(blockId)
}
})
}
findAllDescendants(id)
// If the currently edited block is among the blocks being removed, clear selection to reset the panel
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
if (currentEditedBlockId && blocksToRemove.has(currentEditedBlockId)) {
usePanelEditorStore.getState().clearCurrentBlock()
}
// Capture state before removal, including all nested blocks with subblock values
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
const capturedBlocks: Record<string, BlockState> = {}
blocksToRemove.forEach((blockId) => {
if (allBlocks[blockId]) {
capturedBlocks[blockId] = allBlocks[blockId]
}
})
// Capture all edges connected to any of the blocks being removed
const edges = workflowStore.edges.filter(
(edge) => blocksToRemove.has(edge.source) || blocksToRemove.has(edge.target)
)
if (Object.keys(capturedBlocks).length > 0) {
undoRedo.recordRemoveBlock(id, capturedBlocks[id], edges, capturedBlocks)
}
executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id))
},
[executeQueuedOperation, workflowStore, cancelOperationsForBlock, undoRedo, activeWorkflowId]
)
const collaborativeUpdateBlockPosition = useCallback(
(id: string, position: Position, commit = true) => {
if (commit) {
executeQueuedOperation('update-position', 'block', { id, position, commit }, () => {
workflowStore.updateBlockPosition(id, position)
})
return
}
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () => {
updates.forEach(({ id, position }) => {
workflowStore.updateBlockPosition(id, position)
})
},
[executeQueuedDebouncedOperation, executeQueuedOperation, workflowStore]
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore]
)
const collaborativeUpdateBlockName = useCallback(
@@ -1333,148 +1067,6 @@ export function useCollaborativeWorkflow() {
]
)
const collaborativeDuplicateBlock = useCallback(
(sourceId: string) => {
if (!isInActiveRoom()) {
logger.debug('Skipping duplicate block - not in active workflow', {
currentWorkflowId,
activeWorkflowId,
sourceId,
})
return
}
const sourceBlock = workflowStore.blocks[sourceId]
if (!sourceBlock) return
// Prevent duplication of start blocks (both legacy starter and unified start_trigger)
if (sourceBlock.type === 'starter' || sourceBlock.type === 'start_trigger') {
logger.warn('Cannot duplicate start block - only one start block allowed per workflow', {
blockId: sourceId,
blockType: sourceBlock.type,
})
return
}
// Generate new ID and calculate position
const newId = crypto.randomUUID()
const offsetPosition = {
x: sourceBlock.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: sourceBlock.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks)
// Get subblock values from the store, excluding webhook-specific fields
const allSubBlockValues =
subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {}
const subBlockValues = Object.fromEntries(
Object.entries(allSubBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
// Merge subblock structure with actual values
const mergedSubBlocks = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in mergedSubBlocks) {
delete mergedSubBlocks[field]
}
})
Object.entries(subBlockValues).forEach(([subblockId, value]) => {
if (mergedSubBlocks[subblockId]) {
mergedSubBlocks[subblockId].value = value
} else {
// Create subblock if it doesn't exist in structure
mergedSubBlocks[subblockId] = {
id: subblockId,
type: 'unknown',
value: value,
}
}
})
// Create the complete block data for the socket operation
const duplicatedBlockData = {
sourceId,
id: newId,
type: sourceBlock.type,
name: newName,
position: offsetPosition,
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
subBlocks: mergedSubBlocks,
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
parentId: sourceBlock.data?.parentId || null,
extent: sourceBlock.data?.extent || null,
enabled: sourceBlock.enabled ?? true,
horizontalHandles: sourceBlock.horizontalHandles ?? true,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
workflowStore.addBlock(
newId,
sourceBlock.type,
newName,
offsetPosition,
sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
sourceBlock.data?.parentId,
sourceBlock.data?.extent,
{
enabled: sourceBlock.enabled,
horizontalHandles: sourceBlock.horizontalHandles,
advancedMode: sourceBlock.advancedMode,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height,
}
)
// Focus the newly duplicated block in the editor
usePanelEditorStore.getState().setCurrentBlockId(newId)
executeQueuedOperation('duplicate', 'block', duplicatedBlockData, () => {
workflowStore.addBlock(
newId,
sourceBlock.type,
newName,
offsetPosition,
sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
sourceBlock.data?.parentId,
sourceBlock.data?.extent,
{
enabled: sourceBlock.enabled,
horizontalHandles: sourceBlock.horizontalHandles,
advancedMode: sourceBlock.advancedMode,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height,
}
)
// Apply subblock values locally for immediate UI feedback
// The server will persist these values as part of the block creation
if (activeWorkflowId && Object.keys(subBlockValues).length > 0) {
Object.entries(subBlockValues).forEach(([subblockId, value]) => {
subBlockStore.setValue(newId, subblockId, value)
})
}
// Record for undo after the block is added
undoRedo.recordDuplicateBlock(sourceId, newId, duplicatedBlockData, undefined)
})
},
[
executeQueuedOperation,
workflowStore,
subBlockStore,
activeWorkflowId,
isInActiveRoom,
currentWorkflowId,
undoRedo,
]
)
const collaborativeUpdateLoopType = useCallback(
(loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => {
const currentBlock = workflowStore.blocks[loopId]
@@ -1714,23 +1306,196 @@ export function useCollaborativeWorkflow() {
[executeQueuedOperation, variablesStore, cancelOperationsForVariable]
)
const collaborativeDuplicateVariable = useCallback(
(variableId: string) => {
const newId = crypto.randomUUID()
const sourceVariable = useVariablesStore.getState().variables[variableId]
if (!sourceVariable) return null
const collaborativeBatchAddBlocks = useCallback(
(
blocks: BlockState[],
edges: Edge[] = [],
loops: Record<string, Loop> = {},
parallels: Record<string, Parallel> = {},
subBlockValues: Record<string, Record<string, unknown>> = {},
options?: { skipUndoRedo?: boolean }
) => {
if (!isInActiveRoom()) {
logger.debug('Skipping batch add blocks - not in active workflow')
return false
}
executeQueuedOperation(
'duplicate',
'variable',
{ sourceVariableId: variableId, id: newId },
() => {
variablesStore.duplicateVariable(variableId, newId)
}
)
return newId
if (isBaselineDiffView) {
logger.debug('Skipping batch add blocks while viewing baseline diff')
return false
}
if (blocks.length === 0) return false
logger.info('Batch adding blocks collaboratively', {
blockCount: blocks.length,
edgeCount: edges.length,
})
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: 'batch-add-blocks',
target: 'blocks',
payload: { blocks, edges, loops, parallels, subBlockValues },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
blocks.forEach((block) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode ?? false,
height: block.height,
}
)
})
edges.forEach((edge) => {
workflowStore.addEdge(edge)
})
if (Object.keys(loops).length > 0) {
useWorkflowStore.setState((state) => ({
loops: { ...state.loops, ...loops },
}))
}
if (Object.keys(parallels).length > 0) {
useWorkflowStore.setState((state) => ({
parallels: { ...state.parallels, ...parallels },
}))
}
if (activeWorkflowId) {
Object.entries(subBlockValues).forEach(([blockId, subBlocks]) => {
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
subBlockStore.setValue(blockId, subBlockId, value)
})
})
}
if (!options?.skipUndoRedo) {
undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues)
}
return true
},
[executeQueuedOperation, variablesStore]
[
addToQueue,
activeWorkflowId,
session?.user?.id,
isBaselineDiffView,
isInActiveRoom,
workflowStore,
subBlockStore,
undoRedo,
]
)
const collaborativeBatchRemoveBlocks = useCallback(
(blockIds: string[], options?: { skipUndoRedo?: boolean }) => {
if (!isInActiveRoom()) {
logger.debug('Skipping batch remove blocks - not in active workflow')
return false
}
if (blockIds.length === 0) return false
blockIds.forEach((id) => cancelOperationsForBlock(id))
const allBlocksToRemove = new Set<string>(blockIds)
const findAllDescendants = (parentId: string) => {
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
if (block.data?.parentId === parentId) {
allBlocksToRemove.add(blockId)
findAllDescendants(blockId)
}
})
}
blockIds.forEach((id) => findAllDescendants(id))
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
if (currentEditedBlockId && allBlocksToRemove.has(currentEditedBlockId)) {
usePanelEditorStore.getState().clearCurrentBlock()
}
const mergedBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
const blockSnapshots: BlockState[] = []
const subBlockValues: Record<string, Record<string, unknown>> = {}
allBlocksToRemove.forEach((blockId) => {
const block = mergedBlocks[blockId]
if (block) {
blockSnapshots.push(block)
if (block.subBlocks) {
const values: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
values[subBlockId] = subBlock.value
}
})
if (Object.keys(values).length > 0) {
subBlockValues[blockId] = values
}
}
}
})
const edgeSnapshots = workflowStore.edges.filter(
(e) => allBlocksToRemove.has(e.source) || allBlocksToRemove.has(e.target)
)
logger.info('Batch removing blocks collaboratively', {
requestedCount: blockIds.length,
totalCount: allBlocksToRemove.size,
})
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: 'batch-remove-blocks',
target: 'blocks',
payload: { ids: Array.from(allBlocksToRemove) },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
blockIds.forEach((id) => {
workflowStore.removeBlock(id)
})
if (!options?.skipUndoRedo && blockSnapshots.length > 0) {
undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues)
}
return true
},
[
addToQueue,
activeWorkflowId,
session?.user?.id,
isInActiveRoom,
workflowStore,
cancelOperationsForBlock,
undoRedo,
]
)
return {
@@ -1745,16 +1510,15 @@ export function useCollaborativeWorkflow() {
leaveWorkflow,
// Collaborative operations
collaborativeAddBlock,
collaborativeUpdateBlockPosition,
collaborativeBatchUpdatePositions,
collaborativeUpdateBlockName,
collaborativeRemoveBlock,
collaborativeToggleBlockEnabled,
collaborativeUpdateParentId,
collaborativeToggleBlockAdvancedMode,
collaborativeToggleBlockTriggerMode,
collaborativeToggleBlockHandles,
collaborativeDuplicateBlock,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeAddEdge,
collaborativeRemoveEdge,
collaborativeSetSubblockValue,
@@ -1764,7 +1528,6 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateVariable,
collaborativeAddVariable,
collaborativeDeleteVariable,
collaborativeDuplicateVariable,
// Collaborative loop/parallel operations
collaborativeUpdateLoopType,

View File

@@ -5,11 +5,11 @@ import { useSession } from '@/lib/auth/auth-client'
import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations'
import { useOperationQueue } from '@/stores/operation-queue/store'
import {
type BatchAddBlocksOperation,
type BatchRemoveBlocksOperation,
createOperationEntry,
type DuplicateBlockOperation,
type MoveBlockOperation,
type Operation,
type RemoveBlockOperation,
type RemoveEdgeOperation,
runWithUndoRedoRecordingSuspended,
type UpdateParentOperation,
@@ -17,7 +17,7 @@ import {
} from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -32,89 +32,94 @@ export function useUndoRedo() {
const userId = session?.user?.id || 'unknown'
const recordAddBlock = useCallback(
(blockId: string, autoConnectEdge?: Edge) => {
if (!activeWorkflowId) return
const recordBatchAddBlocks = useCallback(
(
blockSnapshots: BlockState[],
edgeSnapshots: Edge[] = [],
subBlockValues: Record<string, Record<string, unknown>> = {}
) => {
if (!activeWorkflowId || blockSnapshots.length === 0) return
const operation: Operation = {
const operation: BatchAddBlocksOperation = {
id: crypto.randomUUID(),
type: 'add-block',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockId },
}
// Get fresh state from store
const currentBlocks = useWorkflowStore.getState().blocks
const merged = mergeSubblockState(currentBlocks, activeWorkflowId, blockId)
const blockSnapshot = merged[blockId] || currentBlocks[blockId]
const edgesToRemove = autoConnectEdge ? [autoConnectEdge] : []
const inverse: RemoveBlockOperation = {
id: crypto.randomUUID(),
type: 'remove-block',
type: 'batch-add-blocks',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: {
blockId,
blockSnapshot,
edgeSnapshots: edgesToRemove,
blockSnapshots,
edgeSnapshots,
subBlockValues,
},
}
const inverse: BatchRemoveBlocksOperation = {
id: crypto.randomUUID(),
type: 'batch-remove-blocks',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: {
blockSnapshots,
edgeSnapshots,
subBlockValues,
},
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded add block', {
blockId,
hasAutoConnect: !!autoConnectEdge,
edgeCount: edgesToRemove.length,
logger.debug('Recorded batch add blocks', {
blockCount: blockSnapshots.length,
edgeCount: edgeSnapshots.length,
workflowId: activeWorkflowId,
hasSnapshot: !!blockSnapshot,
})
},
[activeWorkflowId, userId, undoRedoStore]
)
const recordRemoveBlock = useCallback(
const recordBatchRemoveBlocks = useCallback(
(
blockId: string,
blockSnapshot: BlockState,
edgeSnapshots: Edge[],
allBlockSnapshots?: Record<string, BlockState>
blockSnapshots: BlockState[],
edgeSnapshots: Edge[] = [],
subBlockValues: Record<string, Record<string, unknown>> = {}
) => {
if (!activeWorkflowId) return
if (!activeWorkflowId || blockSnapshots.length === 0) return
const operation: RemoveBlockOperation = {
const operation: BatchRemoveBlocksOperation = {
id: crypto.randomUUID(),
type: 'remove-block',
type: 'batch-remove-blocks',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: {
blockId,
blockSnapshot,
blockSnapshots,
edgeSnapshots,
allBlockSnapshots,
subBlockValues,
},
}
const inverse: Operation = {
const inverse: BatchAddBlocksOperation = {
id: crypto.randomUUID(),
type: 'add-block',
type: 'batch-add-blocks',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockId },
data: {
blockSnapshots,
edgeSnapshots,
subBlockValues,
},
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded remove block', { blockId, workflowId: activeWorkflowId })
logger.debug('Recorded batch remove blocks', {
blockCount: blockSnapshots.length,
edgeCount: edgeSnapshots.length,
workflowId: activeWorkflowId,
})
},
[activeWorkflowId, userId, undoRedoStore]
)
@@ -227,51 +232,6 @@ export function useUndoRedo() {
[activeWorkflowId, userId, undoRedoStore]
)
const recordDuplicateBlock = useCallback(
(
sourceBlockId: string,
duplicatedBlockId: string,
duplicatedBlockSnapshot: BlockState,
autoConnectEdge?: Edge
) => {
if (!activeWorkflowId) return
const operation: DuplicateBlockOperation = {
id: crypto.randomUUID(),
type: 'duplicate-block',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: {
sourceBlockId,
duplicatedBlockId,
duplicatedBlockSnapshot,
autoConnectEdge,
},
}
// Inverse is to remove the duplicated block
const inverse: RemoveBlockOperation = {
id: crypto.randomUUID(),
type: 'remove-block',
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: {
blockId: duplicatedBlockId,
blockSnapshot: duplicatedBlockSnapshot,
edgeSnapshots: autoConnectEdge ? [autoConnectEdge] : [],
},
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded duplicate block', { sourceBlockId, duplicatedBlockId })
},
[activeWorkflowId, userId, undoRedoStore]
)
const recordUpdateParent = useCallback(
(
blockId: string,
@@ -347,204 +307,117 @@ export function useUndoRedo() {
const opId = crypto.randomUUID()
switch (entry.inverse.type) {
case 'remove-block': {
const removeInverse = entry.inverse as RemoveBlockOperation
const blockId = removeInverse.data.blockId
case 'batch-remove-blocks': {
const batchRemoveOp = entry.inverse as BatchRemoveBlocksOperation
const { blockSnapshots } = batchRemoveOp.data
const blockIds = blockSnapshots.map((b) => b.id)
if (workflowStore.blocks[blockId]) {
// Refresh inverse snapshot to capture the latest subblock values and edges at undo time
const mergedNow = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId)
const latestBlockSnapshot = mergedNow[blockId] || workflowStore.blocks[blockId]
const latestEdgeSnapshots = workflowStore.edges.filter(
(e) => e.source === blockId || e.target === blockId
)
removeInverse.data.blockSnapshot = latestBlockSnapshot
removeInverse.data.edgeSnapshots = latestEdgeSnapshots
// First remove the edges that were added with the block (autoConnect edge)
const edgesToRemove = removeInverse.data.edgeSnapshots || []
edgesToRemove.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
// Send edge removal to server
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'remove',
target: 'edge',
payload: { id: edge.id },
},
workflowId: activeWorkflowId,
userId,
})
}
})
// Then remove the block
addToQueue({
id: opId,
operation: {
operation: 'remove',
target: 'block',
payload: { id: blockId, isUndo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.removeBlock(blockId)
} else {
logger.debug('Undo remove-block skipped; block missing', {
blockId,
})
}
break
}
case 'add-block': {
const originalOp = entry.operation as RemoveBlockOperation
const { blockSnapshot, edgeSnapshots, allBlockSnapshots } = originalOp.data
if (!blockSnapshot || workflowStore.blocks[blockSnapshot.id]) {
logger.debug('Undo add-block skipped', {
hasSnapshot: Boolean(blockSnapshot),
exists: Boolean(blockSnapshot && workflowStore.blocks[blockSnapshot.id]),
})
const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
if (existingBlockIds.length === 0) {
logger.debug('Undo batch-remove-blocks skipped; no blocks exist')
break
}
// Preserve the original name from the snapshot on undo
const restoredName = blockSnapshot.name
const latestEdges = workflowStore.edges.filter(
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target)
)
batchRemoveOp.data.edgeSnapshots = latestEdges
const latestSubBlockValues: Record<string, Record<string, unknown>> = {}
existingBlockIds.forEach((blockId) => {
const merged = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId)
const block = merged[blockId]
if (block?.subBlocks) {
const values: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
values[subBlockId] = subBlock.value
}
})
if (Object.keys(values).length > 0) {
latestSubBlockValues[blockId] = values
}
}
})
batchRemoveOp.data.subBlockValues = latestSubBlockValues
// FIRST: Add the main block (parent subflow) with subBlocks in payload
addToQueue({
id: opId,
operation: {
operation: 'add',
target: 'block',
operation: 'batch-remove-blocks',
target: 'blocks',
payload: { ids: existingBlockIds },
},
workflowId: activeWorkflowId,
userId,
})
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
break
}
case 'batch-add-blocks': {
const batchAddOp = entry.operation as BatchAddBlocksOperation
const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data
const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id])
if (blocksToAdd.length === 0) {
logger.debug('Undo batch-add-blocks skipped; all blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
...blockSnapshot,
name: restoredName,
subBlocks: blockSnapshot.subBlocks || {},
autoConnectEdge: undefined,
isUndo: true,
originalOpId: entry.id,
blocks: blocksToAdd,
edges: edgeSnapshots || [],
loops: {},
parallels: {},
subBlockValues: subBlockValues || {},
},
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.addBlock(
blockSnapshot.id,
blockSnapshot.type,
restoredName,
blockSnapshot.position,
blockSnapshot.data,
blockSnapshot.data?.parentId,
blockSnapshot.data?.extent,
{
enabled: blockSnapshot.enabled,
horizontalHandles: blockSnapshot.horizontalHandles,
advancedMode: blockSnapshot.advancedMode,
triggerMode: blockSnapshot.triggerMode,
height: blockSnapshot.height,
}
)
// Set subblock values for the main block locally
if (blockSnapshot.subBlocks && activeWorkflowId) {
const subblockValues: Record<string, any> = {}
Object.entries(blockSnapshot.subBlocks).forEach(
([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subblockValues[subBlockId] = subBlock.value
}
blocksToAdd.forEach((block) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode,
height: block.height,
}
)
})
if (Object.keys(subblockValues).length > 0) {
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[blockSnapshot.id]: subblockValues,
},
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
...subBlockValues,
},
}))
}
},
}))
}
// SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists
if (allBlockSnapshots) {
Object.entries(allBlockSnapshots).forEach(([id, snap]: [string, any]) => {
if (id !== blockSnapshot.id && !workflowStore.blocks[id]) {
// Preserve original nested block name from snapshot on undo
const restoredNestedName = snap.name
// Add nested block locally
workflowStore.addBlock(
snap.id,
snap.type,
restoredNestedName,
snap.position,
snap.data,
snap.data?.parentId,
snap.data?.extent,
{
enabled: snap.enabled,
horizontalHandles: snap.horizontalHandles,
advancedMode: snap.advancedMode,
triggerMode: snap.triggerMode,
height: snap.height,
}
)
// Send to server with subBlocks included in payload
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'add',
target: 'block',
payload: {
...snap,
name: restoredNestedName,
subBlocks: snap.subBlocks || {},
autoConnectEdge: undefined,
isUndo: true,
originalOpId: entry.id,
},
},
workflowId: activeWorkflowId,
userId,
})
// Restore subblock values for nested blocks locally
if (snap.subBlocks && activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState()
Object.entries(snap.subBlocks).forEach(
([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockStore.setValue(snap.id, subBlockId, subBlock.value)
}
}
)
}
}
})
}
// THIRD: Finally restore edges after all blocks exist
if (edgeSnapshots && edgeSnapshots.length > 0) {
edgeSnapshots.forEach((edge) => {
workflowStore.addEdge(edge)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'add',
target: 'edge',
payload: edge,
},
workflowId: activeWorkflowId,
userId,
})
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.addEdge(edge)
}
})
}
break
@@ -639,49 +512,6 @@ export function useUndoRedo() {
}
break
}
case 'duplicate-block': {
// Undo duplicate means removing the duplicated block
const dupOp = entry.operation as DuplicateBlockOperation
const duplicatedId = dupOp.data.duplicatedBlockId
if (workflowStore.blocks[duplicatedId]) {
// Remove any edges connected to the duplicated block
const edges = workflowStore.edges.filter(
(edge) => edge.source === duplicatedId || edge.target === duplicatedId
)
edges.forEach((edge) => {
workflowStore.removeEdge(edge.id)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'remove',
target: 'edge',
payload: { id: edge.id },
},
workflowId: activeWorkflowId,
userId,
})
})
// Remove the duplicated block
addToQueue({
id: opId,
operation: {
operation: 'remove',
target: 'block',
payload: { id: duplicatedId, isUndo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.removeBlock(duplicatedId)
} else {
logger.debug('Undo duplicate-block skipped; duplicated block missing', {
duplicatedId,
})
}
break
}
case 'update-parent': {
// Undo parent update means reverting to the old parent and position
const updateOp = entry.inverse as UpdateParentOperation
@@ -963,189 +793,96 @@ export function useUndoRedo() {
const opId = crypto.randomUUID()
switch (entry.operation.type) {
case 'add-block': {
// Redo should re-apply the original add: add the block first, then edges
const inv = entry.inverse as RemoveBlockOperation
const snap = inv.data.blockSnapshot
const edgeSnapshots = inv.data.edgeSnapshots || []
const allBlockSnapshots = inv.data.allBlockSnapshots
case 'batch-add-blocks': {
const batchOp = entry.operation as BatchAddBlocksOperation
const { blockSnapshots, edgeSnapshots, subBlockValues } = batchOp.data
if (!snap || workflowStore.blocks[snap.id]) {
const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id])
if (blocksToAdd.length === 0) {
logger.debug('Redo batch-add-blocks skipped; all blocks exist')
break
}
// Preserve the original name from the snapshot on redo
const restoredName = snap.name
// FIRST: Add the main block (parent subflow) with subBlocks included
addToQueue({
id: opId,
operation: {
operation: 'add',
target: 'block',
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
...snap,
name: restoredName,
subBlocks: snap.subBlocks || {},
isRedo: true,
originalOpId: entry.id,
blocks: blocksToAdd,
edges: edgeSnapshots || [],
loops: {},
parallels: {},
subBlockValues: subBlockValues || {},
},
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.addBlock(
snap.id,
snap.type,
restoredName,
snap.position,
snap.data,
snap.data?.parentId,
snap.data?.extent,
{
enabled: snap.enabled,
horizontalHandles: snap.horizontalHandles,
advancedMode: snap.advancedMode,
triggerMode: snap.triggerMode,
height: snap.height,
}
)
// Set subblock values for the main block locally
if (snap.subBlocks && activeWorkflowId) {
const subblockValues: Record<string, any> = {}
Object.entries(snap.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subblockValues[subBlockId] = subBlock.value
blocksToAdd.forEach((block) => {
workflowStore.addBlock(
block.id,
block.type,
block.name,
block.position,
block.data,
block.data?.parentId,
block.data?.extent,
{
enabled: block.enabled,
horizontalHandles: block.horizontalHandles,
advancedMode: block.advancedMode,
triggerMode: block.triggerMode,
height: block.height,
}
})
if (Object.keys(subblockValues).length > 0) {
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[snap.id]: subblockValues,
},
},
}))
}
}
// SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists
if (allBlockSnapshots) {
Object.entries(allBlockSnapshots).forEach(([id, snapNested]: [string, any]) => {
if (id !== snap.id && !workflowStore.blocks[id]) {
// Preserve original nested block name from snapshot on redo
const restoredNestedName = snapNested.name
// Add nested block locally
workflowStore.addBlock(
snapNested.id,
snapNested.type,
restoredNestedName,
snapNested.position,
snapNested.data,
snapNested.data?.parentId,
snapNested.data?.extent,
{
enabled: snapNested.enabled,
horizontalHandles: snapNested.horizontalHandles,
advancedMode: snapNested.advancedMode,
triggerMode: snapNested.triggerMode,
height: snapNested.height,
}
)
// Send to server with subBlocks included
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'add',
target: 'block',
payload: {
...snapNested,
name: restoredNestedName,
subBlocks: snapNested.subBlocks || {},
autoConnectEdge: undefined,
isRedo: true,
originalOpId: entry.id,
},
},
workflowId: activeWorkflowId,
userId,
})
// Restore subblock values for nested blocks locally
if (snapNested.subBlocks && activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState()
Object.entries(snapNested.subBlocks).forEach(
([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockStore.setValue(snapNested.id, subBlockId, subBlock.value)
}
}
)
}
}
})
}
// THIRD: Finally restore edges after all blocks exist
edgeSnapshots.forEach((edge) => {
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.addEdge(edge)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'add',
target: 'edge',
payload: { ...edge, isRedo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
}
)
})
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
...subBlockValues,
},
},
}))
}
if (edgeSnapshots && edgeSnapshots.length > 0) {
edgeSnapshots.forEach((edge) => {
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.addEdge(edge)
}
})
}
break
}
case 'remove-block': {
// Redo should re-apply the original remove: remove edges first, then block
const blockId = entry.operation.data.blockId
const edgesToRemove = (entry.operation as RemoveBlockOperation).data.edgeSnapshots || []
edgesToRemove.forEach((edge) => {
if (workflowStore.edges.find((e) => e.id === edge.id)) {
workflowStore.removeEdge(edge.id)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'remove',
target: 'edge',
payload: { id: edge.id, isRedo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
}
case 'batch-remove-blocks': {
const batchOp = entry.operation as BatchRemoveBlocksOperation
const { blockSnapshots } = batchOp.data
const blockIds = blockSnapshots.map((b) => b.id)
const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
if (existingBlockIds.length === 0) {
logger.debug('Redo batch-remove-blocks skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: 'batch-remove-blocks',
target: 'blocks',
payload: { ids: existingBlockIds },
},
workflowId: activeWorkflowId,
userId,
})
if (workflowStore.blocks[blockId]) {
addToQueue({
id: opId,
operation: {
operation: 'remove',
target: 'block',
payload: { id: blockId, isRedo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.removeBlock(blockId)
} else {
logger.debug('Redo remove-block skipped; block missing', { blockId })
}
existingBlockIds.forEach((id) => workflowStore.removeBlock(id))
break
}
case 'add-edge': {
@@ -1230,100 +967,6 @@ export function useUndoRedo() {
}
break
}
case 'duplicate-block': {
// Redo duplicate means re-adding the duplicated block
const dupOp = entry.operation as DuplicateBlockOperation
const { duplicatedBlockSnapshot, autoConnectEdge } = dupOp.data
if (!duplicatedBlockSnapshot || workflowStore.blocks[duplicatedBlockSnapshot.id]) {
logger.debug('Redo duplicate-block skipped', {
hasSnapshot: Boolean(duplicatedBlockSnapshot),
exists: Boolean(
duplicatedBlockSnapshot && workflowStore.blocks[duplicatedBlockSnapshot.id]
),
})
break
}
const currentBlocks = useWorkflowStore.getState().blocks
const uniqueName = getUniqueBlockName(duplicatedBlockSnapshot.name, currentBlocks)
// Add the duplicated block
addToQueue({
id: opId,
operation: {
operation: 'duplicate',
target: 'block',
payload: {
...duplicatedBlockSnapshot,
name: uniqueName,
subBlocks: duplicatedBlockSnapshot.subBlocks || {},
autoConnectEdge,
isRedo: true,
originalOpId: entry.id,
},
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.addBlock(
duplicatedBlockSnapshot.id,
duplicatedBlockSnapshot.type,
uniqueName,
duplicatedBlockSnapshot.position,
duplicatedBlockSnapshot.data,
duplicatedBlockSnapshot.data?.parentId,
duplicatedBlockSnapshot.data?.extent,
{
enabled: duplicatedBlockSnapshot.enabled,
horizontalHandles: duplicatedBlockSnapshot.horizontalHandles,
advancedMode: duplicatedBlockSnapshot.advancedMode,
triggerMode: duplicatedBlockSnapshot.triggerMode,
height: duplicatedBlockSnapshot.height,
}
)
// Restore subblock values
if (duplicatedBlockSnapshot.subBlocks && activeWorkflowId) {
const subblockValues: Record<string, any> = {}
Object.entries(duplicatedBlockSnapshot.subBlocks).forEach(
([subBlockId, subBlock]: [string, any]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
subblockValues[subBlockId] = subBlock.value
}
}
)
if (Object.keys(subblockValues).length > 0) {
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[duplicatedBlockSnapshot.id]: subblockValues,
},
},
}))
}
}
// Add auto-connect edge if present
if (autoConnectEdge && !workflowStore.edges.find((e) => e.id === autoConnectEdge.id)) {
workflowStore.addEdge(autoConnectEdge)
addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'add',
target: 'edge',
payload: { ...autoConnectEdge, isRedo: true, originalOpId: entry.id },
},
workflowId: activeWorkflowId,
userId,
})
}
break
}
case 'update-parent': {
// Redo parent update means applying the new parent and position
const updateOp = entry.operation as UpdateParentOperation
@@ -1726,12 +1369,11 @@ export function useUndoRedo() {
)
return {
recordAddBlock,
recordRemoveBlock,
recordBatchAddBlocks,
recordBatchRemoveBlocks,
recordAddEdge,
recordRemoveEdge,
recordMove,
recordDuplicateBlock,
recordUpdateParent,
recordApplyDiff,
recordAcceptDiff,

View File

@@ -173,6 +173,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
case 'block':
await handleBlockOperationTx(tx, workflowId, op, payload)
break
case 'blocks':
await handleBlocksOperationTx(tx, workflowId, op, payload)
break
case 'edge':
await handleEdgeOperationTx(tx, workflowId, op, payload)
break
@@ -216,107 +219,6 @@ async function handleBlockOperationTx(
payload: any
) {
switch (operation) {
case 'add': {
// Validate required fields for add operation
if (!payload.id || !payload.type || !payload.name || !payload.position) {
throw new Error('Missing required fields for add block operation')
}
logger.debug(`Adding block: ${payload.type} (${payload.id})`, {
isSubflowType: isSubflowBlockType(payload.type),
})
// Extract parentId and extent from payload.data if they exist there, otherwise from payload directly
const parentId = payload.parentId || payload.data?.parentId || null
const extent = payload.extent || payload.data?.extent || null
logger.debug(`Block parent info:`, {
blockId: payload.id,
hasParent: !!parentId,
parentId,
extent,
payloadParentId: payload.parentId,
dataParentId: payload.data?.parentId,
})
try {
const insertData = {
id: payload.id,
workflowId,
type: payload.type,
name: payload.name,
positionX: payload.position.x,
positionY: payload.position.y,
data: {
...(payload.data || {}),
...(parentId ? { parentId } : {}),
...(extent ? { extent } : {}),
},
subBlocks: payload.subBlocks || {},
outputs: payload.outputs || {},
enabled: payload.enabled ?? true,
horizontalHandles: payload.horizontalHandles ?? true,
advancedMode: payload.advancedMode ?? false,
triggerMode: payload.triggerMode ?? false,
height: payload.height || 0,
}
await tx.insert(workflowBlocks).values(insertData)
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`❌ Failed to insert block ${payload.id}:`, insertError)
throw insertError
}
// Auto-create subflow entry for loop/parallel blocks
if (isSubflowBlockType(payload.type)) {
try {
const subflowConfig =
payload.type === SubflowType.LOOP
? {
id: payload.id,
nodes: [], // Empty initially, will be populated when child blocks are added
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType: payload.data?.loopType || 'for',
// Set the appropriate field based on loop type
...(payload.data?.loopType === 'while'
? { whileCondition: payload.data?.whileCondition || '' }
: payload.data?.loopType === 'doWhile'
? { doWhileCondition: payload.data?.doWhileCondition || '' }
: { forEachItems: payload.data?.collection || '' }),
}
: {
id: payload.id,
nodes: [], // Empty initially, will be populated when child blocks are added
distribution: payload.data?.collection || '',
count: payload.data?.count || DEFAULT_PARALLEL_COUNT,
parallelType: payload.data?.parallelType || 'count',
}
logger.debug(`Auto-creating ${payload.type} subflow ${payload.id}:`, subflowConfig)
await tx.insert(workflowSubflows).values({
id: payload.id,
workflowId,
type: payload.type,
config: subflowConfig,
})
} catch (subflowError) {
logger.error(`❌ Failed to create ${payload.type} subflow ${payload.id}:`, subflowError)
throw subflowError
}
}
// If this block has a parent, update the parent's subflow node list
if (parentId) {
await updateSubflowNodeList(tx, workflowId, parentId)
}
logger.debug(`Added block ${payload.id} (${payload.type}) to workflow ${workflowId}`)
break
}
case 'update-position': {
if (!payload.id || !payload.position) {
throw new Error('Missing required fields for update position operation')
@@ -342,156 +244,6 @@ async function handleBlockOperationTx(
break
}
case 'remove': {
if (!payload.id) {
throw new Error('Missing block ID for remove operation')
}
// Collect all block IDs that will be deleted (including child blocks)
const blocksToDelete = new Set<string>([payload.id])
// Check if this is a subflow block that needs cascade deletion
const blockToRemove = await tx
.select({
type: workflowBlocks.type,
parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
// Cascade delete: Remove all child blocks first
const childBlocks = await tx
.select({ id: workflowBlocks.id, type: workflowBlocks.type })
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${payload.id}`
)
)
logger.debug(
`Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})`
)
logger.debug(
`Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]`
)
// Add child blocks to deletion set
childBlocks.forEach((child: { id: string; type: string }) => blocksToDelete.add(child.id))
// Remove edges connected to child blocks
for (const childBlock of childBlocks) {
await tx
.delete(workflowEdges)
.where(
and(
eq(workflowEdges.workflowId, workflowId),
or(
eq(workflowEdges.sourceBlockId, childBlock.id),
eq(workflowEdges.targetBlockId, childBlock.id)
)
)
)
}
// Remove child blocks from database
await tx
.delete(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${payload.id}`
)
)
// Remove the subflow entry
await tx
.delete(workflowSubflows)
.where(
and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId))
)
}
// Clean up external webhooks before deleting blocks
try {
const blockIdsArray = Array.from(blocksToDelete)
const webhooksToCleanup = await tx
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray)))
if (webhooksToCleanup.length > 0) {
logger.debug(
`Found ${webhooksToCleanup.length} webhook(s) to cleanup for blocks: ${blockIdsArray.join(', ')}`
)
const requestId = `socket-${workflowId}-${Date.now()}-${Math.random().toString(36).substring(7)}`
// Clean up each webhook (don't fail if cleanup fails)
for (const webhookData of webhooksToCleanup) {
try {
await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId)
} catch (cleanupError) {
logger.error(`Failed to cleanup external webhook during block deletion`, {
webhookId: webhookData.webhook.id,
workflowId: webhookData.workflow.id,
userId: webhookData.workflow.userId,
workspaceId: webhookData.workflow.workspaceId,
provider: webhookData.webhook.provider,
blockId: webhookData.webhook.blockId,
error: cleanupError,
})
// Continue with deletion even if cleanup fails
}
}
}
} catch (webhookCleanupError) {
logger.error(`Error during webhook cleanup for block deletion (continuing with deletion)`, {
workflowId,
blockIds: Array.from(blocksToDelete),
error: webhookCleanupError,
})
// Continue with block deletion even if webhook cleanup fails
}
// Remove any edges connected to this block
await tx
.delete(workflowEdges)
.where(
and(
eq(workflowEdges.workflowId, workflowId),
or(
eq(workflowEdges.sourceBlockId, payload.id),
eq(workflowEdges.targetBlockId, payload.id)
)
)
)
// Finally remove the block itself
await tx
.delete(workflowBlocks)
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
// If this block had a parent, update the parent's subflow node list
if (blockToRemove.length > 0 && blockToRemove[0].parentId) {
await updateSubflowNodeList(tx, workflowId, blockToRemove[0].parentId)
}
logger.debug(`Removed block ${payload.id} and its connections from workflow ${workflowId}`)
break
}
case 'update-name': {
if (!payload.id || !payload.name) {
throw new Error('Missing required fields for update name operation')
@@ -677,114 +429,272 @@ async function handleBlockOperationTx(
break
}
case 'duplicate': {
// Validate required fields for duplicate operation
if (!payload.sourceId || !payload.id || !payload.type || !payload.name || !payload.position) {
throw new Error('Missing required fields for duplicate block operation')
}
logger.debug(`Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`, {
isSubflowType: isSubflowBlockType(payload.type),
payload,
})
// Extract parentId and extent from payload
const parentId = payload.parentId || null
const extent = payload.extent || null
try {
const insertData = {
id: payload.id,
workflowId,
type: payload.type,
name: payload.name,
positionX: payload.position.x,
positionY: payload.position.y,
data: {
...(payload.data || {}),
...(parentId ? { parentId } : {}),
...(extent ? { extent } : {}),
},
subBlocks: payload.subBlocks || {},
outputs: payload.outputs || {},
enabled: payload.enabled ?? true,
horizontalHandles: payload.horizontalHandles ?? true,
advancedMode: payload.advancedMode ?? false,
triggerMode: payload.triggerMode ?? false,
height: payload.height || 0,
}
await tx.insert(workflowBlocks).values(insertData)
// Handle auto-connect edge if present
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`❌ Failed to insert duplicated block ${payload.id}:`, insertError)
throw insertError
}
// Auto-create subflow entry for loop/parallel blocks
if (isSubflowBlockType(payload.type)) {
try {
const subflowConfig =
payload.type === SubflowType.LOOP
? {
id: payload.id,
nodes: [], // Empty initially, will be populated when child blocks are added
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType: payload.data?.loopType || 'for',
// Set the appropriate field based on loop type
...(payload.data?.loopType === 'while'
? { whileCondition: payload.data?.whileCondition || '' }
: payload.data?.loopType === 'doWhile'
? { doWhileCondition: payload.data?.doWhileCondition || '' }
: { forEachItems: payload.data?.collection || '' }),
}
: {
id: payload.id,
nodes: [], // Empty initially, will be populated when child blocks are added
distribution: payload.data?.collection || '',
}
logger.debug(
`Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`,
subflowConfig
)
await tx.insert(workflowSubflows).values({
id: payload.id,
workflowId,
type: payload.type,
config: subflowConfig,
})
} catch (subflowError) {
logger.error(
`❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`,
subflowError
)
throw subflowError
}
}
// If this block has a parent, update the parent's subflow node list
if (parentId) {
await updateSubflowNodeList(tx, workflowId, parentId)
}
logger.debug(
`Duplicated block ${payload.sourceId} -> ${payload.id} (${payload.type}) in workflow ${workflowId}`
)
break
}
// Add other block operations as needed
default:
logger.warn(`Unknown block operation: ${operation}`)
throw new Error(`Unsupported block operation: ${operation}`)
}
}
// Edge operations
async function handleBlocksOperationTx(
tx: any,
workflowId: string,
operation: string,
payload: any
) {
switch (operation) {
case 'batch-update-positions': {
const { updates } = payload
if (!Array.isArray(updates) || updates.length === 0) {
return
}
for (const update of updates) {
const { id, position } = update
if (!id || !position) continue
await tx
.update(workflowBlocks)
.set({
positionX: position.x,
positionY: position.y,
})
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
}
break
}
case 'batch-add-blocks': {
const { blocks, edges, loops, parallels } = payload
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
blockCount: blocks?.length || 0,
edgeCount: edges?.length || 0,
loopCount: Object.keys(loops || {}).length,
parallelCount: Object.keys(parallels || {}).length,
})
if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => ({
id: block.id as string,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}))
await tx.insert(workflowBlocks).values(blockValues)
// Create subflow entries for loop/parallel blocks (skip if already in payload)
const loopIds = new Set(loops ? Object.keys(loops) : [])
const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
for (const block of blocks) {
const blockId = block.id as string
if (block.type === 'loop' && !loopIds.has(blockId)) {
await tx.insert(workflowSubflows).values({
id: blockId,
workflowId,
type: 'loop',
config: {
loopType: 'for',
iterations: DEFAULT_LOOP_ITERATIONS,
nodes: [],
},
})
} else if (block.type === 'parallel' && !parallelIds.has(blockId)) {
await tx.insert(workflowSubflows).values({
id: blockId,
workflowId,
type: 'parallel',
config: {
parallelType: 'fixed',
count: DEFAULT_PARALLEL_COUNT,
nodes: [],
},
})
}
}
// Update parent subflow node lists
const parentIds = new Set<string>()
for (const block of blocks) {
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
if (parentId) {
parentIds.add(parentId)
}
}
for (const parentId of parentIds) {
await updateSubflowNodeList(tx, workflowId, parentId)
}
}
if (edges && edges.length > 0) {
const edgeValues = edges.map((edge: Record<string, unknown>) => ({
id: edge.id as string,
workflowId,
sourceBlockId: edge.source as string,
targetBlockId: edge.target as string,
sourceHandle: (edge.sourceHandle as string | null) || null,
targetHandle: (edge.targetHandle as string | null) || null,
}))
await tx.insert(workflowEdges).values(edgeValues)
}
if (loops && Object.keys(loops).length > 0) {
const loopValues = Object.entries(loops).map(([id, loop]) => ({
id,
workflowId,
type: 'loop',
config: loop as Record<string, unknown>,
}))
await tx.insert(workflowSubflows).values(loopValues)
}
if (parallels && Object.keys(parallels).length > 0) {
const parallelValues = Object.entries(parallels).map(([id, parallel]) => ({
id,
workflowId,
type: 'parallel',
config: parallel as Record<string, unknown>,
}))
await tx.insert(workflowSubflows).values(parallelValues)
}
logger.info(`Successfully batch added blocks to workflow ${workflowId}`)
break
}
case 'batch-remove-blocks': {
const { ids } = payload
if (!Array.isArray(ids) || ids.length === 0) {
return
}
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
// Collect all block IDs including children of subflows
const allBlocksToDelete = new Set<string>(ids)
for (const id of ids) {
const blockToRemove = await tx
.select({ type: workflowBlocks.type })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
const childBlocks = await tx
.select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${id}`
)
)
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
}
}
const blockIdsArray = Array.from(allBlocksToDelete)
// Collect parent IDs BEFORE deleting blocks
const parentIds = new Set<string>()
for (const id of ids) {
const parentInfo = await tx
.select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (parentInfo.length > 0 && parentInfo[0].parentId) {
parentIds.add(parentInfo[0].parentId)
}
}
// Clean up external webhooks
const webhooksToCleanup = await tx
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray)))
if (webhooksToCleanup.length > 0) {
const requestId = `socket-batch-${workflowId}-${Date.now()}`
for (const { webhook: wh, workflow: wf } of webhooksToCleanup) {
try {
await cleanupExternalWebhook(wh, wf, requestId)
} catch (error) {
logger.error(`Failed to cleanup webhook ${wh.id}:`, error)
}
}
}
// Delete edges connected to any of the blocks
await tx
.delete(workflowEdges)
.where(
and(
eq(workflowEdges.workflowId, workflowId),
or(
inArray(workflowEdges.sourceBlockId, blockIdsArray),
inArray(workflowEdges.targetBlockId, blockIdsArray)
)
)
)
// Delete subflow entries
await tx
.delete(workflowSubflows)
.where(
and(
eq(workflowSubflows.workflowId, workflowId),
inArray(workflowSubflows.id, blockIdsArray)
)
)
// Delete all blocks
await tx
.delete(workflowBlocks)
.where(
and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIdsArray))
)
// Update parent subflow node lists using pre-collected parent IDs
for (const parentId of parentIds) {
await updateSubflowNodeList(tx, workflowId, parentId)
}
logger.info(
`Successfully batch removed ${blockIdsArray.length} blocks from workflow ${workflowId}`
)
break
}
default:
throw new Error(`Unsupported blocks operation: ${operation}`)
}
}
async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) {
switch (operation) {
case 'add': {
@@ -1013,53 +923,6 @@ async function handleVariableOperationTx(
break
}
case 'duplicate': {
if (!payload.sourceVariableId || !payload.id) {
throw new Error('Missing required fields for duplicate variable operation')
}
const sourceVariable = currentVariables[payload.sourceVariableId]
if (!sourceVariable) {
throw new Error(`Source variable ${payload.sourceVariableId} not found`)
}
// Create duplicated variable with unique name
const baseName = `${sourceVariable.name} (copy)`
let uniqueName = baseName
let nameIndex = 1
// Ensure name uniqueness
const existingNames = Object.values(currentVariables).map((v: any) => v.name)
while (existingNames.includes(uniqueName)) {
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
const duplicatedVariable = {
...sourceVariable,
id: payload.id,
name: uniqueName,
}
const updatedVariables = {
...currentVariables,
[payload.id]: duplicatedVariable,
}
await tx
.update(workflow)
.set({
variables: updatedVariables,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
logger.debug(
`Duplicated variable ${payload.sourceVariableId} -> ${payload.id} (${uniqueName}) in workflow ${workflowId}`
)
break
}
default:
logger.warn(`Unknown variable operation: ${operation}`)
throw new Error(`Unsupported variable operation: ${operation}`)

View File

@@ -145,7 +145,46 @@ export function setupOperationsHandlers(
return
}
if (target === 'variable' && ['add', 'remove', 'duplicate'].includes(operation)) {
if (target === 'blocks' && operation === 'batch-update-positions') {
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID(), isBatchPositionUpdate: true },
})
try {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
} catch (error) {
logger.error('Failed to persist batch position update:', error)
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: error instanceof Error ? error.message : 'Database persistence failed',
retryable: true,
})
}
}
return
}
if (target === 'variable' && ['add', 'remove'].includes(operation)) {
// Persist first, then broadcast
await persistWorkflowOperation(workflowId, {
operation,
@@ -184,7 +223,6 @@ export function setupOperationsHandlers(
}
if (target === 'workflow' && operation === 'replace-state') {
// Persist the workflow state replacement to database first
await persistWorkflowOperation(workflowId, {
operation,
target,
@@ -221,6 +259,64 @@ export function setupOperationsHandlers(
return
}
if (target === 'blocks' && operation === 'batch-add-blocks') {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (target === 'blocks' && operation === 'batch-remove-blocks') {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
// For non-position operations, persist first then broadcast
await persistWorkflowOperation(workflowId, {
operation,

View File

@@ -294,13 +294,21 @@ describe('Socket Server Index Integration', () => {
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
const validOperation = {
operation: 'add',
target: 'block',
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
blocks: [
{
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
},
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
}
@@ -308,30 +316,39 @@ describe('Socket Server Index Integration', () => {
expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow()
})
it.concurrent('should validate block operations with autoConnectEdge', async () => {
it.concurrent('should validate batch-add-blocks with edges', async () => {
const { WorkflowOperationSchema } = await import('@/socket/validation/schemas')
const validOperationWithAutoEdge = {
operation: 'add',
target: 'block',
const validOperationWithEdge = {
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
autoConnectEdge: {
id: 'auto-edge-123',
source: 'source-block',
target: 'test-block',
sourceHandle: 'output',
targetHandle: 'target',
type: 'workflowEdge',
},
blocks: [
{
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
},
],
edges: [
{
id: 'auto-edge-123',
source: 'source-block',
target: 'test-block',
sourceHandle: 'output',
targetHandle: 'target',
type: 'workflowEdge',
},
],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validOperationWithAutoEdge)).not.toThrow()
expect(() => WorkflowOperationSchema.parse(validOperationWithEdge)).not.toThrow()
})
it.concurrent('should validate edge operations', async () => {

View File

@@ -27,13 +27,13 @@ describe('checkRolePermission', () => {
}
})
it('should allow add operation', () => {
const result = checkRolePermission('admin', 'add')
it('should allow batch-add-blocks operation', () => {
const result = checkRolePermission('admin', 'batch-add-blocks')
expectPermissionAllowed(result)
})
it('should allow remove operation', () => {
const result = checkRolePermission('admin', 'remove')
it('should allow batch-remove-blocks operation', () => {
const result = checkRolePermission('admin', 'batch-remove-blocks')
expectPermissionAllowed(result)
})
@@ -42,8 +42,8 @@ describe('checkRolePermission', () => {
expectPermissionAllowed(result)
})
it('should allow duplicate operation', () => {
const result = checkRolePermission('admin', 'duplicate')
it('should allow batch-update-positions operation', () => {
const result = checkRolePermission('admin', 'batch-update-positions')
expectPermissionAllowed(result)
})
@@ -63,13 +63,13 @@ describe('checkRolePermission', () => {
}
})
it('should allow add operation', () => {
const result = checkRolePermission('write', 'add')
it('should allow batch-add-blocks operation', () => {
const result = checkRolePermission('write', 'batch-add-blocks')
expectPermissionAllowed(result)
})
it('should allow remove operation', () => {
const result = checkRolePermission('write', 'remove')
it('should allow batch-remove-blocks operation', () => {
const result = checkRolePermission('write', 'batch-remove-blocks')
expectPermissionAllowed(result)
})
@@ -85,14 +85,14 @@ describe('checkRolePermission', () => {
expectPermissionAllowed(result)
})
it('should deny add operation for read role', () => {
const result = checkRolePermission('read', 'add')
it('should deny batch-add-blocks operation for read role', () => {
const result = checkRolePermission('read', 'batch-add-blocks')
expectPermissionDenied(result, 'read')
expectPermissionDenied(result, 'add')
expectPermissionDenied(result, 'batch-add-blocks')
})
it('should deny remove operation for read role', () => {
const result = checkRolePermission('read', 'remove')
it('should deny batch-remove-blocks operation for read role', () => {
const result = checkRolePermission('read', 'batch-remove-blocks')
expectPermissionDenied(result, 'read')
})
@@ -101,9 +101,9 @@ describe('checkRolePermission', () => {
expectPermissionDenied(result, 'read')
})
it('should deny duplicate operation for read role', () => {
const result = checkRolePermission('read', 'duplicate')
expectPermissionDenied(result, 'read')
it('should allow batch-update-positions operation for read role', () => {
const result = checkRolePermission('read', 'batch-update-positions')
expectPermissionAllowed(result)
})
it('should deny replace-state operation for read role', () => {
@@ -117,7 +117,8 @@ describe('checkRolePermission', () => {
})
it('should deny all write operations for read role', () => {
const writeOperations = SOCKET_OPERATIONS.filter((op) => op !== 'update-position')
const readAllowedOps = ['update-position', 'batch-update-positions']
const writeOperations = SOCKET_OPERATIONS.filter((op) => !readAllowedOps.includes(op))
for (const operation of writeOperations) {
const result = checkRolePermission('read', operation)
@@ -138,7 +139,7 @@ describe('checkRolePermission', () => {
})
it('should deny operations for empty role', () => {
const result = checkRolePermission('', 'add')
const result = checkRolePermission('', 'batch-add-blocks')
expectPermissionDenied(result)
})
})
@@ -186,15 +187,21 @@ describe('checkRolePermission', () => {
it('should verify read has minimal permissions', () => {
const readOps = ROLE_ALLOWED_OPERATIONS.read
expect(readOps).toHaveLength(1)
expect(readOps).toHaveLength(2)
expect(readOps).toContain('update-position')
expect(readOps).toContain('batch-update-positions')
})
})
describe('specific operations', () => {
const testCases = [
{ operation: 'add', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'remove', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'batch-add-blocks', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-remove-blocks',
adminAllowed: true,
writeAllowed: true,
readAllowed: false,
},
{ operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true },
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
@@ -214,7 +221,12 @@ describe('checkRolePermission', () => {
readAllowed: false,
},
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'duplicate', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-update-positions',
adminAllowed: true,
writeAllowed: true,
readAllowed: true,
},
{ operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false },
]
@@ -238,13 +250,13 @@ describe('checkRolePermission', () => {
describe('reason messages', () => {
it('should include role in denial reason', () => {
const result = checkRolePermission('read', 'add')
const result = checkRolePermission('read', 'batch-add-blocks')
expect(result.reason).toContain("'read'")
})
it('should include operation in denial reason', () => {
const result = checkRolePermission('read', 'add')
expect(result.reason).toContain("'add'")
const result = checkRolePermission('read', 'batch-add-blocks')
expect(result.reason).toContain("'batch-add-blocks'")
})
it('should have descriptive denial message format', () => {

View File

@@ -13,6 +13,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
'remove',
'update',
'update-position',
'batch-update-positions',
'batch-add-blocks',
'batch-remove-blocks',
'update-name',
'toggle-enabled',
'update-parent',
@@ -20,7 +23,6 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'duplicate',
'replace-state',
],
write: [
@@ -28,6 +30,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
'remove',
'update',
'update-position',
'batch-update-positions',
'batch-add-blocks',
'batch-remove-blocks',
'update-name',
'toggle-enabled',
'update-parent',
@@ -35,10 +40,9 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'duplicate',
'replace-state',
],
read: ['update-position'],
read: ['update-position', 'batch-update-positions'],
}
// Check if a role allows a specific operation (no DB query, pure logic)

View File

@@ -103,18 +103,26 @@ describe('Socket Server Integration Tests', () => {
const operationPromise = new Promise<void>((resolve) => {
client2.once('workflow-operation', (data) => {
expect(data.operation).toBe('add')
expect(data.target).toBe('block')
expect(data.payload.id).toBe('block-123')
expect(data.operation).toBe('batch-add-blocks')
expect(data.target).toBe('blocks')
expect(data.payload.blocks[0].id).toBe('block-123')
resolve()
})
})
clientSocket.emit('workflow-operation', {
workflowId,
operation: 'add',
target: 'block',
payload: { id: 'block-123', type: 'action', name: 'Test Block' },
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{ id: 'block-123', type: 'action', name: 'Test Block', position: { x: 0, y: 0 } },
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
@@ -170,9 +178,17 @@ describe('Socket Server Integration Tests', () => {
clients[0].emit('workflow-operation', {
workflowId,
operation: 'add',
target: 'block',
payload: { id: 'stress-block', type: 'action' },
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{ id: 'stress-block', type: 'action', name: 'Stress Block', position: { x: 0, y: 0 } },
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
@@ -211,7 +227,7 @@ describe('Socket Server Integration Tests', () => {
const operationsPromise = new Promise<void>((resolve) => {
client2.on('workflow-operation', (data) => {
receivedCount++
receivedOperations.add(data.payload.id)
receivedOperations.add(data.payload.blocks[0].id)
if (receivedCount === numOperations) {
resolve()
@@ -222,9 +238,22 @@ describe('Socket Server Integration Tests', () => {
for (let i = 0; i < numOperations; i++) {
clientSocket.emit('workflow-operation', {
workflowId,
operation: 'add',
target: 'block',
payload: { id: `rapid-block-${i}`, type: 'action' },
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{
id: `rapid-block-${i}`,
type: 'action',
name: `Rapid Block ${i}`,
position: { x: 0, y: 0 },
},
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
}

View File

@@ -17,8 +17,6 @@ const AutoConnectEdgeSchema = z.object({
export const BlockOperationSchema = z.object({
operation: z.enum([
'add',
'remove',
'update-position',
'update-name',
'toggle-enabled',
@@ -27,12 +25,10 @@ export const BlockOperationSchema = z.object({
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'duplicate',
]),
target: z.literal('block'),
payload: z.object({
id: z.string(),
sourceId: z.string().optional(), // For duplicate operations
type: z.string().optional(),
name: z.string().optional(),
position: PositionSchema.optional(),
@@ -47,7 +43,21 @@ export const BlockOperationSchema = z.object({
advancedMode: z.boolean().optional(),
triggerMode: z.boolean().optional(),
height: z.number().optional(),
autoConnectEdge: AutoConnectEdgeSchema.optional(), // Add support for auto-connect edges
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchPositionUpdateSchema = z.object({
operation: z.literal('batch-update-positions'),
target: z.literal('blocks'),
payload: z.object({
updates: z.array(
z.object({
id: z.string(),
position: PositionSchema,
})
),
}),
timestamp: z.number(),
operationId: z.string().optional(),
@@ -102,23 +112,37 @@ export const VariableOperationSchema = z.union([
timestamp: z.number(),
operationId: z.string().optional(),
}),
z.object({
operation: z.literal('duplicate'),
target: z.literal('variable'),
payload: z.object({
sourceVariableId: z.string(),
id: z.string(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
}),
])
export const WorkflowStateOperationSchema = z.object({
operation: z.literal('replace-state'),
target: z.literal('workflow'),
payload: z.object({
state: z.any(), // Full workflow state
state: z.any(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchAddBlocksSchema = z.object({
operation: z.literal('batch-add-blocks'),
target: z.literal('blocks'),
payload: z.object({
blocks: z.array(z.record(z.any())),
edges: z.array(AutoConnectEdgeSchema).optional(),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
subBlockValues: z.record(z.record(z.any())).optional(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchRemoveBlocksSchema = z.object({
operation: z.literal('batch-remove-blocks'),
target: z.literal('blocks'),
payload: z.object({
ids: z.array(z.string()),
}),
timestamp: z.number(),
operationId: z.string().optional(),
@@ -126,6 +150,9 @@ export const WorkflowStateOperationSchema = z.object({
export const WorkflowOperationSchema = z.union([
BlockOperationSchema,
BatchPositionUpdateSchema,
BatchAddBlocksSchema,
BatchRemoveBlocksSchema,
EdgeOperationSchema,
SubflowOperationSchema,
VariableOperationSchema,

View File

@@ -375,15 +375,31 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
cancelOperationsForBlock: (blockId: string) => {
logger.debug('Canceling all operations for block', { blockId })
// No debounced timeouts to cancel (moved to server-side)
// Find and cancel operation timeouts for operations related to this block
const state = get()
const operationsToCancel = state.operations.filter(
(op) =>
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
)
const operationsToCancel = state.operations.filter((op) => {
const { target, payload, operation } = op.operation
// Single block property updates (update-position, toggle-enabled, update-name, etc.)
if (target === 'block' && payload?.id === blockId) return true
// Subblock updates for this block
if (target === 'subblock' && payload?.blockId === blockId) return true
// Batch block operations
if (target === 'blocks') {
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
return payload.blocks.some((b: { id: string }) => b.id === blockId)
}
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
return payload.ids.includes(blockId)
}
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
return payload.updates.some((u: { id: string }) => u.id === blockId)
}
}
return false
})
// Cancel timeouts for these operations
operationsToCancel.forEach((op) => {
@@ -401,13 +417,30 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
})
// Remove all operations for this block (both pending and processing)
const newOperations = state.operations.filter(
(op) =>
!(
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
)
)
const newOperations = state.operations.filter((op) => {
const { target, payload, operation } = op.operation
// Single block property updates (update-position, toggle-enabled, update-name, etc.)
if (target === 'block' && payload?.id === blockId) return false
// Subblock updates for this block
if (target === 'subblock' && payload?.blockId === blockId) return false
// Batch block operations
if (target === 'blocks') {
if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) {
if (payload.blocks.some((b: { id: string }) => b.id === blockId)) return false
}
if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) {
if (payload.ids.includes(blockId)) return false
}
if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) {
if (payload.updates.some((u: { id: string }) => u.id === blockId)) return false
}
}
return true
})
set({
operations: newOperations,

View File

@@ -22,6 +22,10 @@ interface PanelEditorState {
setConnectionsHeight: (height: number) => void
/** Toggle connections between collapsed (min height) and expanded (default height) */
toggleConnectionsCollapsed: () => void
/** Flag to signal the editor to focus the rename input */
shouldFocusRename: boolean
/** Sets the shouldFocusRename flag */
setShouldFocusRename: (value: boolean) => void
}
/**
@@ -33,6 +37,8 @@ export const usePanelEditorStore = create<PanelEditorState>()(
(set, get) => ({
currentBlockId: null,
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
shouldFocusRename: false,
setShouldFocusRename: (value) => set({ shouldFocusRename: value }),
setCurrentBlockId: (blockId) => {
set({ currentBlockId: blockId })
@@ -79,6 +85,10 @@ export const usePanelEditorStore = create<PanelEditorState>()(
}),
{
name: 'panel-editor-state',
partialize: (state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
}),
onRehydrateStorage: () => (state) => {
// Sync CSS variables with stored state after rehydration
if (state && typeof window !== 'undefined') {

View File

@@ -283,39 +283,6 @@ export const useVariablesStore = create<VariablesStore>()(
})
},
duplicateVariable: (id, providedId?: string) => {
const state = get()
if (!state.variables[id]) return ''
const variable = state.variables[id]
const newId = providedId || crypto.randomUUID()
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
const baseName = `${variable.name} (copy)`
let uniqueName = baseName
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
set((state) => ({
variables: {
...state.variables,
[newId]: {
id: newId,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value,
},
},
}))
return newId
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},

View File

@@ -43,12 +43,6 @@ export interface VariablesStore {
deleteVariable: (id: string) => void
/**
* Duplicates a variable with a "(copy)" suffix, ensuring name uniqueness
* Optionally accepts a predetermined ID for collaborative operations
*/
duplicateVariable: (id: string, providedId?: string) => string
/**
* Returns all variables for a specific workflow
*/

View File

@@ -14,7 +14,6 @@ import {
createAddBlockEntry,
createAddEdgeEntry,
createBlock,
createDuplicateBlockEntry,
createMockStorage,
createMoveBlockEntry,
createRemoveBlockEntry,
@@ -23,7 +22,7 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
import type { DuplicateBlockOperation, UpdateParentOperation } from '@/stores/undo-redo/types'
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
describe('useUndoRedoStore', () => {
const workflowId = 'wf-test'
@@ -617,63 +616,6 @@ describe('useUndoRedoStore', () => {
})
})
describe('duplicate-block operations', () => {
it('should handle duplicate-block operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
const sourceBlock = createBlock({ id: 'source-block' })
const duplicatedBlock = createBlock({ id: 'duplicated-block' })
push(
workflowId,
userId,
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
workflowId,
userId,
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('duplicate-block')
expect(entry?.inverse.type).toBe('remove-block')
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should store the duplicated block snapshot correctly', () => {
const { push, undo } = useUndoRedoStore.getState()
const duplicatedBlock = createBlock({
id: 'duplicated-block',
name: 'Duplicated Agent',
type: 'agent',
position: { x: 200, y: 200 },
})
push(
workflowId,
userId,
createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, {
workflowId,
userId,
})
)
const entry = undo(workflowId, userId)
const operation = entry?.operation as DuplicateBlockOperation
expect(operation.data.duplicatedBlockSnapshot).toMatchObject({
id: 'duplicated-block',
name: 'Duplicated Agent',
type: 'agent',
position: { x: 200, y: 200 },
})
})
})
describe('update-parent operations', () => {
it('should handle update-parent operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()

View File

@@ -3,10 +3,11 @@ import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import type {
BatchAddBlocksOperation,
BatchRemoveBlocksOperation,
MoveBlockOperation,
Operation,
OperationEntry,
RemoveBlockOperation,
RemoveEdgeOperation,
UndoRedoState,
} from '@/stores/undo-redo/types'
@@ -83,13 +84,13 @@ function isOperationApplicable(
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
): boolean {
switch (operation.type) {
case 'remove-block': {
const op = operation as RemoveBlockOperation
return Boolean(graph.blocksById[op.data.blockId])
case 'batch-remove-blocks': {
const op = operation as BatchRemoveBlocksOperation
return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id]))
}
case 'add-block': {
const blockId = operation.data.blockId
return !graph.blocksById[blockId]
case 'batch-add-blocks': {
const op = operation as BatchAddBlocksOperation
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
}
case 'move-block': {
const op = operation as MoveBlockOperation
@@ -99,10 +100,6 @@ function isOperationApplicable(
const blockId = operation.data.blockId
return Boolean(graph.blocksById[blockId])
}
case 'duplicate-block': {
const duplicatedId = operation.data.duplicatedBlockId
return Boolean(graph.blocksById[duplicatedId])
}
case 'remove-edge': {
const op = operation as RemoveEdgeOperation
return Boolean(graph.edgesById[op.data.edgeId])

View File

@@ -2,15 +2,14 @@ import type { Edge } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
export type OperationType =
| 'add-block'
| 'remove-block'
| 'batch-add-blocks'
| 'batch-remove-blocks'
| 'add-edge'
| 'remove-edge'
| 'add-subflow'
| 'remove-subflow'
| 'move-block'
| 'move-subflow'
| 'duplicate-block'
| 'update-parent'
| 'apply-diff'
| 'accept-diff'
@@ -24,20 +23,21 @@ export interface BaseOperation {
userId: string
}
export interface AddBlockOperation extends BaseOperation {
type: 'add-block'
export interface BatchAddBlocksOperation extends BaseOperation {
type: 'batch-add-blocks'
data: {
blockId: string
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
export interface RemoveBlockOperation extends BaseOperation {
type: 'remove-block'
export interface BatchRemoveBlocksOperation extends BaseOperation {
type: 'batch-remove-blocks'
data: {
blockId: string
blockSnapshot: BlockState | null
edgeSnapshots?: Edge[]
allBlockSnapshots?: Record<string, BlockState>
blockSnapshots: BlockState[]
edgeSnapshots: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
}
}
@@ -103,16 +103,6 @@ export interface MoveSubflowOperation extends BaseOperation {
}
}
export interface DuplicateBlockOperation extends BaseOperation {
type: 'duplicate-block'
data: {
sourceBlockId: string
duplicatedBlockId: string
duplicatedBlockSnapshot: BlockState
autoConnectEdge?: Edge
}
}
export interface UpdateParentOperation extends BaseOperation {
type: 'update-parent'
data: {
@@ -155,15 +145,14 @@ export interface RejectDiffOperation extends BaseOperation {
}
export type Operation =
| AddBlockOperation
| RemoveBlockOperation
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
| AddEdgeOperation
| RemoveEdgeOperation
| AddSubflowOperation
| RemoveSubflowOperation
| MoveBlockOperation
| MoveSubflowOperation
| DuplicateBlockOperation
| UpdateParentOperation
| ApplyDiffOperation
| AcceptDiffOperation

View File

@@ -1,4 +1,9 @@
import type { Operation, OperationEntry } from '@/stores/undo-redo/types'
import type {
BatchAddBlocksOperation,
BatchRemoveBlocksOperation,
Operation,
OperationEntry,
} from '@/stores/undo-redo/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return {
@@ -11,25 +16,31 @@ export function createOperationEntry(operation: Operation, inverse: Operation):
export function createInverseOperation(operation: Operation): Operation {
switch (operation.type) {
case 'add-block':
case 'batch-add-blocks': {
const op = operation as BatchAddBlocksOperation
return {
...operation,
type: 'remove-block',
type: 'batch-remove-blocks',
data: {
blockId: operation.data.blockId,
blockSnapshot: null,
edgeSnapshots: [],
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
}
} as BatchRemoveBlocksOperation
}
case 'remove-block':
case 'batch-remove-blocks': {
const op = operation as BatchRemoveBlocksOperation
return {
...operation,
type: 'add-block',
type: 'batch-add-blocks',
data: {
blockId: operation.data.blockId,
blockSnapshots: op.data.blockSnapshots,
edgeSnapshots: op.data.edgeSnapshots,
subBlockValues: op.data.subBlockValues,
},
}
} as BatchAddBlocksOperation
}
case 'add-edge':
return {
@@ -89,17 +100,6 @@ export function createInverseOperation(operation: Operation): Operation {
},
}
case 'duplicate-block':
return {
...operation,
type: 'remove-block',
data: {
blockId: operation.data.duplicatedBlockId,
blockSnapshot: operation.data.duplicatedBlockSnapshot,
edgeSnapshots: [],
},
}
case 'update-parent':
return {
...operation,
@@ -147,7 +147,7 @@ export function createInverseOperation(operation: Operation): Operation {
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
}
}
}
@@ -155,22 +155,32 @@ export function createInverseOperation(operation: Operation): Operation {
export function operationToCollaborativePayload(operation: Operation): {
operation: string
target: string
payload: any
payload: Record<string, unknown>
} {
switch (operation.type) {
case 'add-block':
case 'batch-add-blocks': {
const op = operation as BatchAddBlocksOperation
return {
operation: 'add',
target: 'block',
payload: { id: operation.data.blockId },
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: op.data.blockSnapshots,
edges: op.data.edgeSnapshots,
loops: {},
parallels: {},
subBlockValues: op.data.subBlockValues,
},
}
}
case 'remove-block':
case 'batch-remove-blocks': {
const op = operation as BatchRemoveBlocksOperation
return {
operation: 'remove',
target: 'block',
payload: { id: operation.data.blockId },
operation: 'batch-remove-blocks',
target: 'blocks',
payload: { ids: op.data.blockSnapshots.map((b) => b.id) },
}
}
case 'add-edge':
return {
@@ -223,16 +233,6 @@ export function operationToCollaborativePayload(operation: Operation): {
},
}
case 'duplicate-block':
return {
operation: 'duplicate',
target: 'block',
payload: {
sourceId: operation.data.sourceBlockId,
duplicatedId: operation.data.duplicatedBlockId,
},
}
case 'update-parent':
return {
operation: 'update-parent',
@@ -274,7 +274,7 @@ export function operationToCollaborativePayload(operation: Operation): {
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
}
}
}

View File

@@ -381,37 +381,6 @@ export const useVariablesStore = create<VariablesStore>()(
})
},
duplicateVariable: (id, providedId) => {
const state = get()
const existing = state.variables[id]
if (!existing) return ''
const newId = providedId || uuidv4()
const workflowVariables = state.getVariablesByWorkflowId(existing.workflowId)
const baseName = `${existing.name} (copy)`
let uniqueName = baseName
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${baseName} (${nameIndex})`
nameIndex++
}
set((state) => ({
variables: {
...state.variables,
[newId]: {
id: newId,
workflowId: existing.workflowId,
name: uniqueName,
type: existing.type,
value: existing.value,
},
},
}))
return newId
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
},

View File

@@ -57,6 +57,5 @@ export interface VariablesStore {
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
duplicateVariable: (id: string, providedId?: string) => string
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -1,119 +1,9 @@
import { createLogger } from '@sim/logger'
import { v4 as uuidv4 } from 'uuid'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
import type { WorkflowState } from '../workflow/types'
const logger = createLogger('WorkflowJsonImporter')
/**
* Generate new IDs for all blocks and edges to avoid conflicts
*/
function regenerateIds(workflowState: WorkflowState): WorkflowState {
const { metadata, variables } = workflowState
const blockIdMap = new Map<string, string>()
const newBlocks: WorkflowState['blocks'] = {}
// First pass: create new IDs for all blocks
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = uuidv4()
blockIdMap.set(oldId, newId)
newBlocks[newId] = {
...block,
id: newId,
}
})
// Second pass: update edges with new block IDs
const newEdges = workflowState.edges.map((edge) => ({
...edge,
id: uuidv4(), // Generate new edge ID
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
// Third pass: update loops with new block IDs
// CRITICAL: Loop IDs must match their block IDs (loops are keyed by their block ID)
const newLoops: WorkflowState['loops'] = {}
if (workflowState.loops) {
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
// Map the loop ID using the block ID mapping (loop ID = block ID)
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
// Fourth pass: update parallels with new block IDs
// CRITICAL: Parallel IDs must match their block IDs (parallels are keyed by their block ID)
const newParallels: WorkflowState['parallels'] = {}
if (workflowState.parallels) {
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
// Map the parallel ID using the block ID mapping (parallel ID = block ID)
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
// Fifth pass: update any block references in subblock values and clear runtime trigger values
Object.entries(newBlocks).forEach(([blockId, block]) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
block.subBlocks[subBlockId] = {
...subBlock,
value: null,
}
return
}
if (subBlock.value && typeof subBlock.value === 'string') {
// Replace any block references in the value
let updatedValue = subBlock.value
blockIdMap.forEach((newId, oldId) => {
// Replace references like <blockId.output> with new IDs
const regex = new RegExp(`<${oldId}\\.`, 'g')
updatedValue = updatedValue.replace(regex, `<${newId}.`)
})
block.subBlocks[subBlockId] = {
...subBlock,
value: updatedValue,
}
}
})
}
// Update parentId references in block.data
if (block.data?.parentId) {
const newParentId = blockIdMap.get(block.data.parentId)
if (newParentId) {
block.data.parentId = newParentId
} else {
// Parent ID not in mapping - this shouldn't happen but log it
logger.warn(`Block ${blockId} references unmapped parent ${block.data.parentId}`)
// Remove invalid parent reference
block.data.parentId = undefined
block.data.extent = undefined
}
}
})
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
metadata,
variables,
}
}
/**
* Normalize subblock values by converting empty strings to null.
* This provides backwards compatibility for workflows exported before the null sanitization fix,
@@ -260,9 +150,10 @@ export function parseWorkflowJson(
variables: Array.isArray(workflowData.variables) ? workflowData.variables : undefined,
}
// Regenerate IDs if requested (default: true)
if (regenerateIdsFlag) {
const regeneratedState = regenerateIds(workflowState)
const { idMap: _, ...regeneratedState } = regenerateWorkflowIds(workflowState, {
clearTriggerRuntimeValues: true,
})
workflowState = {
...regeneratedState,
metadata: workflowState.metadata,

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type {
@@ -12,7 +13,9 @@ import type {
} from '@/stores/workflows/registry/types'
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowRegistry')
const initialHydration: HydrationState = {
@@ -70,12 +73,12 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void {
export const useWorkflowRegistry = create<WorkflowRegistry>()(
devtools(
(set, get) => ({
// Store state
workflows: {},
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
hydration: initialHydration,
clipboard: null,
beginMetadataLoad: (workspaceId: string) => {
set((state) => ({
@@ -772,10 +775,104 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
deploymentStatuses: {},
error: null,
hydration: initialHydration,
clipboard: null,
})
logger.info('Logout complete - all workflow data cleared')
},
copyBlocks: (blockIds: string[]) => {
if (blockIds.length === 0) return
const workflowStore = useWorkflowStore.getState()
const activeWorkflowId = get().activeWorkflowId
const subBlockStore = useSubBlockStore.getState()
const copiedBlocks: Record<string, BlockState> = {}
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
const blockIdSet = new Set(blockIds)
// Auto-include nested nodes from selected subflows
blockIds.forEach((blockId) => {
const loop = workflowStore.loops[blockId]
if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n))
const parallel = workflowStore.parallels[blockId]
if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n))
})
blockIdSet.forEach((blockId) => {
const block = workflowStore.blocks[blockId]
if (block) {
copiedBlocks[blockId] = JSON.parse(JSON.stringify(block))
if (activeWorkflowId) {
const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId]
if (blockValues) {
copiedSubBlockValues[blockId] = JSON.parse(JSON.stringify(blockValues))
}
}
}
})
const copiedEdges = workflowStore.edges.filter(
(edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target)
)
const copiedLoops: Record<string, Loop> = {}
Object.entries(workflowStore.loops).forEach(([loopId, loop]) => {
if (blockIdSet.has(loopId)) {
copiedLoops[loopId] = JSON.parse(JSON.stringify(loop))
}
})
const copiedParallels: Record<string, Parallel> = {}
Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => {
if (blockIdSet.has(parallelId)) {
copiedParallels[parallelId] = JSON.parse(JSON.stringify(parallel))
}
})
set({
clipboard: {
blocks: copiedBlocks,
edges: copiedEdges,
subBlockValues: copiedSubBlockValues,
loops: copiedLoops,
parallels: copiedParallels,
timestamp: Date.now(),
},
})
logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length })
},
preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => {
const { clipboard, activeWorkflowId } = get()
if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null
if (!activeWorkflowId) return null
const workflowStore = useWorkflowStore.getState()
const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds(
clipboard.blocks,
clipboard.edges,
clipboard.loops,
clipboard.parallels,
clipboard.subBlockValues,
positionOffset,
workflowStore.blocks,
getUniqueBlockName
)
return { blocks, edges, loops, parallels, subBlockValues }
},
hasClipboard: () => {
const { clipboard } = get()
return clipboard !== null && Object.keys(clipboard.blocks).length > 0
},
clearClipboard: () => {
set({ clipboard: null })
},
}),
{ name: 'workflow-registry' }
)

View File

@@ -1,3 +1,6 @@
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
@@ -5,6 +8,15 @@ export interface DeploymentStatus {
needsRedeployment?: boolean
}
export interface ClipboardData {
blocks: Record<string, BlockState>
edges: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
loops: Record<string, Loop>
parallels: Record<string, Parallel>
timestamp: number
}
export interface WorkflowMetadata {
id: string
name: string
@@ -38,6 +50,7 @@ export interface WorkflowRegistryState {
error: string | null
deploymentStatuses: Record<string, DeploymentStatus>
hydration: HydrationState
clipboard: ClipboardData | null
}
export interface WorkflowRegistryActions {
@@ -58,6 +71,17 @@ export interface WorkflowRegistryActions {
apiKey?: string
) => void
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void
copyBlocks: (blockIds: string[]) => void
preparePasteData: (positionOffset?: { x: number; y: number }) => {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
subBlockValues: Record<string, Record<string, unknown>>
} | null
hasClipboard: () => boolean
clearClipboard: () => void
logout: () => void
}
export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions

View File

@@ -1,9 +1,31 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
import type {
BlockState,
Loop,
Parallel,
Position,
SubBlockState,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
export { normalizeName }
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
idMap: Map<string, string>
}
/**
* Generates a unique block name by finding the highest number suffix among existing blocks
* with the same base name and incrementing it
@@ -44,6 +66,166 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
return `${namePrefix} ${maxNumber + 1}`
}
export interface PrepareBlockStateOptions {
id: string
type: string
name: string
position: Position
data?: Record<string, unknown>
parentId?: string
extent?: 'parent'
triggerMode?: boolean
}
/**
* Prepares a BlockState object from block type and configuration.
* Generates subBlocks and outputs from the block registry.
*/
export function prepareBlockState(options: PrepareBlockStateOptions): BlockState {
const { id, type, name, position, data, parentId, extent, triggerMode = false } = options
const blockConfig = getBlock(type)
const blockData: Record<string, unknown> = { ...(data || {}) }
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
if (!blockConfig) {
return {
id,
type,
name,
position,
data: blockData,
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
}
}
const subBlocks: Record<string, SubBlockState> = {}
if (blockConfig.subBlocks) {
blockConfig.subBlocks.forEach((subBlock) => {
let initialValue: unknown = null
if (typeof subBlock.value === 'function') {
try {
initialValue = subBlock.value({})
} catch {
initialValue = null
}
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format') {
initialValue = [
{
id: crypto.randomUUID(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
} else if (subBlock.type === 'table') {
initialValue = []
}
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: initialValue as SubBlockState['value'],
}
})
}
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
return {
id,
type,
name,
position,
data: blockData,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
}
}
export interface PrepareDuplicateBlockStateOptions {
sourceBlock: BlockState
newId: string
newName: string
positionOffset: { x: number; y: number }
subBlockValues: Record<string, unknown>
}
/**
* Prepares a BlockState for duplicating an existing block.
* Copies block structure and subblock values, excluding webhook fields.
*/
export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): {
block: BlockState
subBlockValues: Record<string, unknown>
} {
const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options
const filteredSubBlockValues = Object.fromEntries(
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in mergedSubBlocks) {
delete mergedSubBlocks[field]
}
})
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
if (mergedSubBlocks[subblockId]) {
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
} else {
mergedSubBlocks[subblockId] = {
id: subblockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
const block: BlockState = {
id: newId,
type: sourceBlock.type,
name: newName,
position: {
x: sourceBlock.position.x + positionOffset.x,
y: sourceBlock.position.y + positionOffset.y,
},
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
subBlocks: mergedSubBlocks,
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
enabled: sourceBlock.enabled ?? true,
horizontalHandles: sourceBlock.horizontalHandles ?? true,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
return { block, subBlockValues: filteredSubBlockValues }
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
@@ -211,6 +393,217 @@ export async function mergeSubblockStateAsync(
})
)
// Convert entries back to an object
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') {
let updatedValue = value
nameMap.forEach((newName, oldName) => {
const regex = new RegExp(`<${oldName}\\.`, 'g')
updatedValue = updatedValue.replace(regex, `<${newName}.`)
})
return updatedValue
}
if (Array.isArray(value)) {
return value.map((item) => updateValueReferences(item, nameMap))
}
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value)) {
result[key] = updateValueReferences(val, nameMap)
}
return result
}
return value
}
function updateBlockReferences(
blocks: Record<string, BlockState>,
idMap: Map<string, string>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
Object.entries(blocks).forEach(([_, block]) => {
if (block.data?.parentId) {
const newParentId = idMap.get(block.data.parentId)
if (newParentId) {
block.data = { ...block.data, parentId: newParentId }
} else {
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
block.subBlocks[subBlockId] = { ...subBlock, value: null }
return
}
if (subBlock.value !== undefined && subBlock.value !== null) {
const updatedValue = updateValueReferences(
subBlock.value,
nameMap
) as SubBlockState['value']
block.subBlocks[subBlockId] = { ...subBlock, value: updatedValue }
}
})
}
})
}
export function regenerateWorkflowIds(
workflowState: WorkflowState,
options: { clearTriggerRuntimeValues?: boolean } = {}
): WorkflowState & { idMap: Map<string, string> } {
const { clearTriggerRuntimeValues = true } = options
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = uuidv4()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
nameMap.set(oldNormalizedName, oldNormalizedName)
newBlocks[newId] = { ...block, id: newId }
})
const newEdges = workflowState.edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newLoops: Record<string, Loop> = {}
if (workflowState.loops) {
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
const newParallels: Record<string, Parallel> = {}
if (workflowState.parallels) {
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues)
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
metadata: workflowState.metadata,
variables: workflowState.variables,
idMap: blockIdMap,
}
}
export function regenerateBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
parallels: Record<string, Parallel>,
subBlockValues: Record<string, Record<string, unknown>>,
positionOffset: { x: number; y: number },
existingBlockNames: Record<string, BlockState>,
uniqueNameFn: (name: string, blocks: Record<string, BlockState>) => string
): RegeneratedState & { subBlockValues: Record<string, Record<string, unknown>> } {
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
const newSubBlockValues: Record<string, Record<string, unknown>> = {}
// Track all blocks for name uniqueness (existing + newly processed)
const allBlocksForNaming = { ...existingBlockNames }
Object.entries(blocks).forEach(([oldId, block]) => {
const newId = uuidv4()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
const newName = uniqueNameFn(block.name, allBlocksForNaming)
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
const isNested = !!block.data?.parentId
const newBlock: BlockState = {
...block,
id: newId,
name: newName,
position: isNested
? block.position
: {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
},
}
newBlocks[newId] = newBlock
// Add to tracking so next block gets unique name
allBlocksForNaming[newId] = newBlock
if (subBlockValues[oldId]) {
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
}
})
const newEdges = edges.map((edge) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
const newLoops: Record<string, Loop> = {}
Object.entries(loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
const newParallels: Record<string, Parallel> = {}
Object.entries(parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
updateBlockReferences(newBlocks, blockIdMap, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => {
blockValues[subBlockId] = updateValueReferences(blockValues[subBlockId], nameMap)
})
})
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
subBlockValues: newSubBlockValues,
idMap: blockIdMap,
}
}

View File

@@ -121,6 +121,7 @@ function handleBodySizeLimitError(error: unknown, requestId: string, context: st
*/
const MCP_SYSTEM_PARAMETERS = new Set([
'serverId',
'serverUrl',
'toolName',
'serverName',
'_context',

View File

@@ -1,15 +1,12 @@
import type {
JsmAddOrganizationToServiceDeskParams,
JsmAddOrganizationToServiceDeskResponse,
} from '@/tools/jsm/types'
import type { JsmAddOrganizationParams, JsmAddOrganizationResponse } from '@/tools/jsm/types'
import type { ToolConfig } from '@/tools/types'
export const jsmAddOrganizationToServiceDeskTool: ToolConfig<
JsmAddOrganizationToServiceDeskParams,
JsmAddOrganizationToServiceDeskResponse
export const jsmAddOrganizationTool: ToolConfig<
JsmAddOrganizationParams,
JsmAddOrganizationResponse
> = {
id: 'jsm_add_organization_to_service_desk',
name: 'JSM Add Organization to Service Desk',
id: 'jsm_add_organization',
name: 'JSM Add Organization',
description: 'Add an organization to a service desk in Jira Service Management',
version: '1.0.0',

View File

@@ -1,6 +1,6 @@
import { jsmAddCommentTool } from '@/tools/jsm/add_comment'
import { jsmAddCustomerTool } from '@/tools/jsm/add_customer'
import { jsmAddOrganizationToServiceDeskTool } from '@/tools/jsm/add_organization_to_service_desk'
import { jsmAddOrganizationTool } from '@/tools/jsm/add_organization'
import { jsmAddParticipantsTool } from '@/tools/jsm/add_participants'
import { jsmAnswerApprovalTool } from '@/tools/jsm/answer_approval'
import { jsmCreateOrganizationTool } from '@/tools/jsm/create_organization'
@@ -22,7 +22,7 @@ import { jsmTransitionRequestTool } from '@/tools/jsm/transition_request'
export {
jsmAddCommentTool,
jsmAddCustomerTool,
jsmAddOrganizationToServiceDeskTool,
jsmAddOrganizationTool,
jsmAddParticipantsTool,
jsmAnswerApprovalTool,
jsmCreateOrganizationTool,

View File

@@ -345,12 +345,12 @@ export interface JsmCreateOrganizationResponse extends ToolResponse {
}
}
export interface JsmAddOrganizationToServiceDeskParams extends JsmBaseParams {
export interface JsmAddOrganizationParams extends JsmBaseParams {
serviceDeskId: string
organizationId: string
}
export interface JsmAddOrganizationToServiceDeskResponse extends ToolResponse {
export interface JsmAddOrganizationResponse extends ToolResponse {
output: {
ts: string
serviceDeskId: string
@@ -462,7 +462,7 @@ export type JsmResponse =
| JsmTransitionRequestResponse
| JsmGetTransitionsResponse
| JsmCreateOrganizationResponse
| JsmAddOrganizationToServiceDeskResponse
| JsmAddOrganizationResponse
| JsmGetParticipantsResponse
| JsmAddParticipantsResponse
| JsmGetApprovalsResponse

View File

@@ -494,7 +494,7 @@ import {
import {
jsmAddCommentTool,
jsmAddCustomerTool,
jsmAddOrganizationToServiceDeskTool,
jsmAddOrganizationTool,
jsmAddParticipantsTool,
jsmAnswerApprovalTool,
jsmCreateOrganizationTool,
@@ -1531,7 +1531,7 @@ export const tools: Record<string, ToolConfig> = {
jsm_add_customer: jsmAddCustomerTool,
jsm_get_organizations: jsmGetOrganizationsTool,
jsm_create_organization: jsmCreateOrganizationTool,
jsm_add_organization_to_service_desk: jsmAddOrganizationToServiceDeskTool,
jsm_add_organization: jsmAddOrganizationTool,
jsm_get_queues: jsmGetQueuesTool,
jsm_get_sla: jsmGetSlaTool,
jsm_get_transitions: jsmGetTransitionsTool,

View File

@@ -120,22 +120,20 @@ export {
} from './serialized-block.factory'
// Undo/redo operation factories
export {
type AddBlockOperation,
type AddEdgeOperation,
type BaseOperation,
type BatchAddBlocksOperation,
type BatchRemoveBlocksOperation,
createAddBlockEntry,
createAddEdgeEntry,
createDuplicateBlockEntry,
createMoveBlockEntry,
createRemoveBlockEntry,
createRemoveEdgeEntry,
createUpdateParentEntry,
type DuplicateBlockOperation,
type MoveBlockOperation,
type Operation,
type OperationEntry,
type OperationType,
type RemoveBlockOperation,
type RemoveEdgeOperation,
type UpdateParentOperation,
} from './undo-redo.factory'

View File

@@ -257,6 +257,8 @@ export function createWorkflowAccessContext(options: {
export const SOCKET_OPERATIONS = [
'add',
'remove',
'batch-add-blocks',
'batch-remove-blocks',
'update',
'update-position',
'update-name',
@@ -266,7 +268,7 @@ export const SOCKET_OPERATIONS = [
'update-advanced-mode',
'update-trigger-mode',
'toggle-handles',
'duplicate',
'batch-update-positions',
'replace-state',
] as const
@@ -278,7 +280,7 @@ export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, SocketOperation[]> = {
admin: [...SOCKET_OPERATIONS],
write: [...SOCKET_OPERATIONS],
read: ['update-position'],
read: ['update-position', 'batch-update-positions'],
}
/**

View File

@@ -6,12 +6,11 @@ import { nanoid } from 'nanoid'
* Operation types supported by the undo/redo store.
*/
export type OperationType =
| 'add-block'
| 'remove-block'
| 'batch-add-blocks'
| 'batch-remove-blocks'
| 'add-edge'
| 'remove-edge'
| 'move-block'
| 'duplicate-block'
| 'update-parent'
/**
@@ -38,22 +37,26 @@ export interface MoveBlockOperation extends BaseOperation {
}
/**
* Add block operation data.
* Batch add blocks operation data.
*/
export interface AddBlockOperation extends BaseOperation {
type: 'add-block'
data: { blockId: string }
export interface BatchAddBlocksOperation extends BaseOperation {
type: 'batch-add-blocks'
data: {
blockSnapshots: any[]
edgeSnapshots: any[]
subBlockValues: Record<string, Record<string, any>>
}
}
/**
* Remove block operation data.
* Batch remove blocks operation data.
*/
export interface RemoveBlockOperation extends BaseOperation {
type: 'remove-block'
export interface BatchRemoveBlocksOperation extends BaseOperation {
type: 'batch-remove-blocks'
data: {
blockId: string
blockSnapshot: any
edgeSnapshots?: any[]
blockSnapshots: any[]
edgeSnapshots: any[]
subBlockValues: Record<string, Record<string, any>>
}
}
@@ -73,18 +76,6 @@ export interface RemoveEdgeOperation extends BaseOperation {
data: { edgeId: string; edgeSnapshot: any }
}
/**
* Duplicate block operation data.
*/
export interface DuplicateBlockOperation extends BaseOperation {
type: 'duplicate-block'
data: {
sourceBlockId: string
duplicatedBlockId: string
duplicatedBlockSnapshot: any
}
}
/**
* Update parent operation data.
*/
@@ -100,12 +91,11 @@ export interface UpdateParentOperation extends BaseOperation {
}
export type Operation =
| AddBlockOperation
| RemoveBlockOperation
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
| AddEdgeOperation
| RemoveEdgeOperation
| MoveBlockOperation
| DuplicateBlockOperation
| UpdateParentOperation
/**
@@ -126,36 +116,51 @@ interface OperationEntryOptions {
}
/**
* Creates a mock add-block operation entry.
* Creates a mock batch-add-blocks operation entry.
*/
export function createAddBlockEntry(blockId: string, options: OperationEntryOptions = {}): any {
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
const timestamp = Date.now()
const mockBlockSnapshot = {
id: blockId,
type: 'action',
name: `Block ${blockId}`,
position: { x: 0, y: 0 },
}
return {
id,
createdAt,
operation: {
id: nanoid(8),
type: 'add-block',
type: 'batch-add-blocks',
timestamp,
workflowId,
userId,
data: { blockId },
data: {
blockSnapshots: [mockBlockSnapshot],
edgeSnapshots: [],
subBlockValues: {},
},
},
inverse: {
id: nanoid(8),
type: 'remove-block',
type: 'batch-remove-blocks',
timestamp,
workflowId,
userId,
data: { blockId, blockSnapshot: null },
data: {
blockSnapshots: [mockBlockSnapshot],
edgeSnapshots: [],
subBlockValues: {},
},
},
}
}
/**
* Creates a mock remove-block operation entry.
* Creates a mock batch-remove-blocks operation entry.
*/
export function createRemoveBlockEntry(
blockId: string,
@@ -165,24 +170,39 @@ export function createRemoveBlockEntry(
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
const timestamp = Date.now()
const snapshotToUse = blockSnapshot || {
id: blockId,
type: 'action',
name: `Block ${blockId}`,
position: { x: 0, y: 0 },
}
return {
id,
createdAt,
operation: {
id: nanoid(8),
type: 'remove-block',
type: 'batch-remove-blocks',
timestamp,
workflowId,
userId,
data: { blockId, blockSnapshot },
data: {
blockSnapshots: [snapshotToUse],
edgeSnapshots: [],
subBlockValues: {},
},
},
inverse: {
id: nanoid(8),
type: 'add-block',
type: 'batch-add-blocks',
timestamp,
workflowId,
userId,
data: { blockId },
data: {
blockSnapshots: [snapshotToUse],
edgeSnapshots: [],
subBlockValues: {},
},
},
}
}
@@ -290,40 +310,6 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions
}
}
/**
* Creates a mock duplicate-block operation entry.
*/
export function createDuplicateBlockEntry(
sourceBlockId: string,
duplicatedBlockId: string,
duplicatedBlockSnapshot: any,
options: OperationEntryOptions = {}
): any {
const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options
const timestamp = Date.now()
return {
id,
createdAt,
operation: {
id: nanoid(8),
type: 'duplicate-block',
timestamp,
workflowId,
userId,
data: { sourceBlockId, duplicatedBlockId, duplicatedBlockSnapshot },
},
inverse: {
id: nanoid(8),
type: 'remove-block',
timestamp,
workflowId,
userId,
data: { blockId: duplicatedBlockId, blockSnapshot: duplicatedBlockSnapshot },
},
}
}
/**
* Creates a mock update-parent operation entry.
*/