From c9fd1934cdb56e37755b26f34dc24585ed2bb5c0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 6 Nov 2025 16:45:33 +0100 Subject: [PATCH] Introduce node multiselect --- frontend/src/lib/components/Dev.svelte | 14 +- .../src/lib/components/FlowBuilder.svelte | 41 ++-- .../src/lib/components/FlowGraphViewer.svelte | 2 +- .../lib/components/FlowPreviewContent.svelte | 6 +- .../components/FlowStatusViewerInner.svelte | 6 +- .../lib/components/copilot/IteratorGen.svelte | 6 +- .../components/copilot/PredicateGen.svelte | 6 +- .../components/copilot/StepInputGen.svelte | 6 +- .../components/copilot/StepInputsGen.svelte | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 2 +- .../copilot/chat/flow/FlowAIChat.svelte | 20 +- .../flows/content/FlowEditorPanel.svelte | 38 ++-- .../flows/content/FlowModuleComponent.svelte | 19 +- .../flows/content/FlowModuleSleep.svelte | 4 +- .../flows/content/FlowModuleSuspend.svelte | 4 +- .../content/FlowModuleWorkerTagSelect.svelte | 4 +- .../flows/content/FlowModuleWrapper.svelte | 28 +-- .../flows/header/FlowPreviewButtons.svelte | 10 +- .../flows/map/FlowErrorHandlerItem.svelte | 10 +- .../flows/map/FlowModuleSchemaMap.svelte | 21 +- .../flows/map/FlowStickyNode.svelte | 10 +- .../lib/components/flows/map/MapItem.svelte | 10 +- frontend/src/lib/components/flows/types.ts | 17 +- .../lib/components/graph/FlowGraphV2.svelte | 144 ++++++++++--- .../graph/SelectionBoundingBox.svelte | 53 +++++ .../lib/components/graph/SelectionTool.svelte | 180 ++++++++++++++++ .../src/lib/components/graph/graphContext.ts | 15 ++ .../graph/renderers/edges/BaseEdge.svelte | 8 +- .../graph/renderers/edges/EmptyEdge.svelte | 7 +- .../graph/renderers/nodes/AIToolNode.svelte | 11 +- .../renderers/nodes/BranchAllEndNode.svelte | 6 +- .../renderers/nodes/BranchAllStart.svelte | 6 +- .../renderers/nodes/BranchOneStart.svelte | 6 +- .../renderers/nodes/ForLoopEndNode.svelte | 8 +- .../renderers/nodes/ForLoopStartNode.svelte | 6 +- .../graph/renderers/nodes/InputNode.svelte | 9 +- .../graph/renderers/nodes/NoBranchNode.svelte | 6 +- .../graph/renderers/nodes/ResultNode.svelte | 9 +- .../graph/renderers/nodes/TriggersNode.svelte | 57 ++--- .../renderers/nodes/branchOneEndNode.svelte | 6 +- .../components/graph/selectionUtils.svelte.ts | 194 ++++++++++++++++++ .../FlowBuilderTutorialForLoop.svelte | 4 +- frontend/src/routes/flows/dev/+page.svelte | 8 +- 43 files changed, 793 insertions(+), 240 deletions(-) create mode 100644 frontend/src/lib/components/graph/SelectionBoundingBox.svelte create mode 100644 frontend/src/lib/components/graph/SelectionTool.svelte create mode 100644 frontend/src/lib/components/graph/graphContext.ts create mode 100644 frontend/src/lib/components/graph/selectionUtils.svelte.ts diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 86ded3bc0d..9be5e33a75 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -31,6 +31,7 @@ import type { FlowState } from './flows/flowState' import { initHistory } from '$lib/history.svelte' import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' + import { SelectionManager } from './graph/selectionUtils.svelte' import { dfs } from './flows/dfs' import { loadSchemaFromModule } from './flows/flowInfers' import { CornerDownLeft, Play } from 'lucide-svelte' @@ -475,7 +476,7 @@ let ids = dfs(flowStore.val.value.modules ?? [], (m) => m.id) flowStateStore.val = Object.fromEntries(ids.map((k) => [k, {}])) } catch (e) {} - inferModuleArgs($selectedIdStore) + inferModuleArgs(selectionManager.getSelectedId()!) } } catch (e) { console.error('issue setting new flowstore', e) @@ -489,7 +490,8 @@ const moving = writable<{ id: string } | undefined>(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() - const selectedIdStore = writable('settings-metadata') + const selectionManager = new SelectionManager() + selectionManager.selectId('settings-metadata') const triggersCount = writable(undefined) const modulesTestStates = new ModulesTestStates((moduleId) => { // console.log('FOO') @@ -508,7 +510,7 @@ let pathStore = writable('') let initialPathStore = writable('') setContext('FlowEditorContext', { - selectedId: selectedIdStore, + selectionManager, previewArgs: previewArgsStore, scriptEditorDrawer, moving, @@ -618,7 +620,7 @@ flowStore.val && untrack(() => updateFlow(flowStore.val)) }) $effect(() => { - $selectedIdStore && untrack(() => inferModuleArgs($selectedIdStore)) + selectionManager.getSelectedId() && untrack(() => inferModuleArgs(selectionManager.getSelectedId()!)) }) let localModuleStates: Record = $state({}) @@ -640,7 +642,7 @@ job.success && flowPreviewButtons?.getPreviewMode() === 'whole' ) { - if (flowModuleSchemaMap?.isNodeVisible('result') && $selectedIdStore !== 'Result') { + if (flowModuleSchemaMap?.isNodeVisible('result') && selectionManager.getSelectedId() !== 'Result') { outputPickerOpenFns['Result']?.() } } else { @@ -846,7 +848,7 @@ on:applyArgs={(ev) => { if (ev.detail.kind === 'preprocessor') { stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {}) - $selectedIdStore = 'preprocessor' + selectionManager.selectId('preprocessor') } else { previewArgsStore.val = ev.detail.args ?? {} flowPreviewButtons?.openPreview() diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 505720bd1b..8934dd91bb 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -43,6 +43,7 @@ import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte' import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte' import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' + import { SelectionManager } from './graph/selectionUtils.svelte' import { cleanInputs } from './flows/utils' import { Calendar, @@ -338,11 +339,11 @@ let savedAtNewPath = false if (newFlow) { - onSaveInitial?.({ path: $pathStore, id: getSelectedId() }) + onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' }) } else if (savedFlow?.draft_only && $pathStore !== initialPath) { savedAtNewPath = true initialPath = $pathStore - onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() }) + onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' }) // this is so we can use the flow builder outside of sveltekit } onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow }) @@ -561,7 +562,7 @@ encodeState({ flow: flowStore.val, path: $pathStore, - selectedId: $selectedIdStore, + selectedId: selectionManager.getSelectedId(), draft_triggers: triggersState.getDraftTriggersSnapshot(), selected_trigger: triggersState.getSelectedTriggerSnapshot(), loadedFromHistory: { @@ -576,10 +577,16 @@ }, 500) } - const selectedIdStore = writable(selectedId ?? 'settings-metadata') + const selectionManager = new SelectionManager() + // Initialize with selected id if provided + if (selectedId) { + selectionManager.selectId(selectedId) + } else { + selectionManager.selectId('settings-metadata') + } export function getSelectedId() { - return $selectedIdStore + return selectionManager.getSelectedId() } const previewArgsStore = $state({ val: initialArgs }) @@ -598,7 +605,7 @@ const stepsInputArgs = new StepsInputArgs() function select(selectedId: string) { - selectedIdStore.set(selectedId) + selectionManager.selectId(selectedId) } let insertButtonOpen = writable(false) @@ -607,7 +614,7 @@ let flowEditor: FlowEditor | undefined = $state(undefined) setContext('FlowEditorContext', { - selectedId: selectedIdStore, + selectionManager, currentEditor: writable(undefined), previewArgs: previewArgsStore, scriptEditorDrawer, @@ -695,7 +702,7 @@ case 'z': if (event.ctrlKey || event.metaKey) { flowStore.val = undo(history, flowStore.val) - $selectedIdStore = 'Input' + selectionManager.selectId('Input') event.preventDefault() } break @@ -708,9 +715,9 @@ case 'ArrowDown': { if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) { let ids = generateIds() - let idx = ids.indexOf($selectedIdStore) + let idx = ids.indexOf(selectionManager.getSelectedId()!) if (idx > -1 && idx < ids.length - 1) { - $selectedIdStore = ids[idx + 1] + selectionManager.selectId(ids[idx + 1]) event.preventDefault() } } @@ -719,9 +726,9 @@ case 'ArrowUp': { if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) { let ids = generateIds() - let idx = ids.indexOf($selectedIdStore) + let idx = ids.indexOf(selectionManager.getSelectedId()!) if (idx > 0 && idx < ids.length) { - $selectedIdStore = ids[idx - 1] + selectionManager.selectId(ids[idx - 1]) event.preventDefault() } } @@ -868,7 +875,7 @@ setContext('customUi', customUi) }) $effect.pre(() => { - if (flowStore.val || $selectedIdStore) { + if (flowStore.val || selectionManager.getSelectedId()) { readFieldsRecursively(flowStore.val) untrack(() => saveSessionDraft()) } @@ -932,7 +939,7 @@ job.success && flowPreviewButtons?.getPreviewMode() === 'whole' ) { - if (flowEditor?.isNodeVisible('result') && $selectedIdStore !== 'Result') { + if (flowEditor?.isNodeVisible('result') && selectionManager.getSelectedId() !== 'Result') { outputPickerOpenFns['Result']?.() } } else { @@ -1026,7 +1033,7 @@ } } - $selectedIdStore = 'Input' + selectionManager.selectId('Input') }} on:redo={() => { flowStore.val = redo(history) @@ -1190,7 +1197,7 @@ on:applyArgs={(ev) => { if (ev.detail.kind === 'preprocessor') { stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {}) - $selectedIdStore = 'preprocessor' + selectionManager.selectId('preprocessor') } }} on:testWithArgs={(e) => { @@ -1203,7 +1210,7 @@ {savedFlow} onDeployTrigger={handleDeployTrigger} onEditInput={(moduleId, key) => { - selectedIdStore.set(moduleId) + selectionManager.selectId(moduleId) // Use new prop-based system forceTestTab[moduleId] = true highlightArg[moduleId] = key diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 5297ed3584..983df472e2 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -46,7 +46,7 @@ failureModule={flow?.value?.failure_module} preprocessorModule={flow?.value?.preprocessor_module} onSelect={(nodeId) => { - if (nodeId === 'triggers') { + if (nodeId === 'Trigger') { dispatch('triggerDetail') return } else if (nodeId === 'failure') { diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 616e7fcac5..cb9251ebcb 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -100,7 +100,7 @@ } const { - selectedId, + selectionManager, previewArgs, flowStateStore, flowStore, @@ -126,7 +126,7 @@ } else { const flow = previewFlow ?? stateSnapshot(flowStore).val const idOrders = dfs(flow.value.modules, (x) => x.id) - let upToIndex = idOrders.indexOf(upToId ?? $selectedId) + let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '') if (upToIndex != -1) { flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders) @@ -430,7 +430,7 @@ {#if previewMode == 'upTo'} Test up to - {$selectedId} + {selectionManager.getSelectedId()} {:else} Test flow diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 4a573bfcc5..0f71308322 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -39,7 +39,6 @@ import type { FlowGraphAssetContext } from './flows/types' import { createState } from '$lib/svelte5Utils.svelte' import JobLoader from './JobLoader.svelte' - import { writable } from 'svelte/store' import { AI_TOOL_CALL_PREFIX, AI_TOOL_MESSAGE_PREFIX, @@ -48,6 +47,7 @@ } from './graph/renderers/nodes/AIToolNode.svelte' import JobAssetsViewer from './assets/JobAssetsViewer.svelte' import McpToolCallDetails from './McpToolCallDetails.svelte' + import { SelectionManager } from './graph/selectionUtils.svelte' let { flowState: flowStateStore, @@ -232,7 +232,7 @@ let expandedSubflows: Record = $state({}) - let selectedId = writable(selectedNode) + let selectionManager = new SelectionManager() function onFlowModuleId() { let modId = flowJobIds?.moduleId @@ -1730,7 +1730,7 @@ {/each} ('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') async function generateIteratorExpr() { if (generatedContent.length > 0 || loading) { @@ -45,7 +45,7 @@ loading = true const flow: Flow = JSON.parse(JSON.stringify(flowStore.val)) const idOrders = dfs(flow.value.modules, (x) => x.id) - const upToIndex = idOrders.indexOf($selectedId) + const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!) if (upToIndex === -1) { throw new Error('Could not find the selected id in the flow') } @@ -60,7 +60,7 @@ flow_input: pickableProperties?.flow_input } const user = `I'm building a workflow which is a DAG of script steps. -The current step is ${$selectedId} and represents a for-loop. You can find the details of all the steps below: +The current step is ${selectionManager.getSelectedId()!} and represents a for-loop. You can find the details of all the steps below: ${flowDetails} Determine the iterator expression to pass either from the previous results or the flow inputs. Here's a summary of the available data: diff --git a/frontend/src/lib/components/copilot/PredicateGen.svelte b/frontend/src/lib/components/copilot/PredicateGen.svelte index 6bfeacd62c..e887f8b6a7 100644 --- a/frontend/src/lib/components/copilot/PredicateGen.svelte +++ b/frontend/src/lib/components/copilot/PredicateGen.svelte @@ -29,7 +29,7 @@ }) let abortController = $state(new AbortController()) - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() @@ -38,7 +38,7 @@ loading = true const flow: Flow = JSON.parse(JSON.stringify(flowStore.val)) const idOrders = dfs(flow.value.modules, (x) => x.id) - const upToIndex = idOrders.indexOf($selectedId) + const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!) if (upToIndex === -1) { throw new Error('Could not find the selected id in the flow') } @@ -53,7 +53,7 @@ flow_input: pickableProperties?.flow_input } const user = `I'm building a workflow which is a DAG of script steps. -The current step is ${$selectedId} and is a branching step (if-else). +The current step is ${selectionManager.getSelectedId()!} and is a branching step (if-else). The user wants to generate a predicate for the branching condition. Here's the user's request: ${instructions} You can find the details of all the steps below: diff --git a/frontend/src/lib/components/copilot/StepInputGen.svelte b/frontend/src/lib/components/copilot/StepInputGen.svelte index 9dec547627..75073db786 100644 --- a/frontend/src/lib/components/copilot/StepInputGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputGen.svelte @@ -54,7 +54,7 @@ let abortController = new AbortController() let newFlowInput = $state('') - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const { stepInputsLoading, generatedExprs } = getContext('FlowCopilotContext') || {} @@ -86,7 +86,7 @@ loading = true const flow: Flow = JSON.parse(JSON.stringify(flowStore.val)) const idOrders = dfs(flow.value.modules, (x) => x.id) - const upToIndex = idOrders.indexOf($selectedId) + const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!) if (upToIndex === -1) { throw new Error('Could not find the selected id in the flow') } @@ -102,7 +102,7 @@ } const isInsideLoop = availableData.flow_input && 'iter' in availableData.flow_input const user = `I'm building a workflow which is a DAG of script steps. -The current step is ${$selectedId}, you can find the details for the step and previous ones below: +The current step is ${selectionManager.getSelectedId()!}, you can find the details for the step and previous ones below: ${flowDetails} Determine for the input "${argName}", what to pass either from the previous results or the flow inputs. All possibles inputs either start with results. or flow_input. and are followed by the key of the input. diff --git a/frontend/src/lib/components/copilot/StepInputsGen.svelte b/frontend/src/lib/components/copilot/StepInputsGen.svelte index 2ce8d4b177..24d2635417 100644 --- a/frontend/src/lib/components/copilot/StepInputsGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputsGen.svelte @@ -30,7 +30,7 @@ let { pickableProperties = undefined, argNames = [], schema = undefined }: Props = $props() - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const { exprsToSet, stepInputsLoading, generatedExprs } = getContext('FlowCopilotContext') || {} @@ -49,7 +49,7 @@ stepInputsLoading?.set(true) const flow: Flow = JSON.parse(JSON.stringify(flowStore.val)) const idOrders = dfs(flow.value.modules, (x) => x.id) - const upToIndex = idOrders.indexOf($selectedId) + const upToIndex = idOrders.indexOf(selectionManager.getSelectedId()!) if (upToIndex === -1) { throw new Error('Could not find the selected id in the flow') } @@ -65,7 +65,7 @@ } const isInsideLoop = availableData.flow_input && 'iter' in availableData.flow_input const user = `I'm building a workflow which is a DAG of script steps. -The current step is ${$selectedId}, you can find the details for the step and previous ones below: +The current step is ${selectionManager.getSelectedId()!}, you can find the details for the step and previous ones below: ${flowDetails} Determine for all the inputs "${argNames.join( diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 53cd319d55..173818a252 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -872,7 +872,7 @@ class AIChatManager { } listenForSelectedIdChanges = ( - selectedId: string, + selectedId: string | undefined, flowStore: ExtendedOpenFlow, flowStateStore: FlowState, currentEditor: CurrentEditor diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index dc48253240..f2496cb0bf 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -25,7 +25,7 @@ flowModuleSchemaMap: FlowModuleSchemaMap | undefined } = $props() - const { flowStore, flowStateStore, selectedId, currentEditor } = + const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext('FlowEditorContext') const { exprsToSet } = getContext('FlowCopilotContext') ?? {} @@ -84,7 +84,7 @@ const flow = $state.snapshot(flowStore).val return { flow, - selectedId: $selectedId + selectedId: selectionManager.getSelectedId()! } }, // flow apply/reject @@ -382,7 +382,7 @@ value: match[2].trim() })) - if (id === $selectedId) { + if (id === selectionManager.getSelectedId()!) { exprsToSet?.set({}) const argsToUpdate = {} for (const { input, value } of parsedInputs) { @@ -421,7 +421,7 @@ setModuleStatus('Input', 'modified') }, selectStep: (id) => { - $selectedId = id + selectionManager.selectId(id) }, getStepCode: (id) => { const module = getModule(id) @@ -611,7 +611,7 @@ $effect(() => { const cleanup = aiChatManager.listenForSelectedIdChanges( - $selectedId, + selectionManager.getSelectedId(), flowStore.val, flowStateStore.val, $currentEditor @@ -626,21 +626,21 @@ // Automatically show revert review when selecting a rawscript module with pending changes $effect(() => { + const selectedId = selectionManager.getSelectedId() if ( $currentEditor?.type === 'script' && - $selectedId && - affectedModules[$selectedId] && + selectedId && + affectedModules[selectedId] && $currentEditor.editor.getAiChatEditorHandler() ) { - const moduleLastSnapshot = getModule($selectedId, lastSnapshot) + const moduleLastSnapshot = getModule(selectedId, lastSnapshot) const content = moduleLastSnapshot?.value.type === 'rawscript' ? moduleLastSnapshot.value.content : '' if (content.length > 0) { untrack(() => $currentEditor.editor.reviewAppliedCode(content, { onFinishedReview: () => { - const id = $selectedId - flowHelpers.acceptModuleAction(id) + flowHelpers.acceptModuleAction(selectedId) $currentEditor.hideDiffMode() } }) diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 0b9621ef39..d5b4be0a3c 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -55,7 +55,7 @@ }: Props = $props() const { - selectedId, + selectionManager, flowStore, flowStateStore, flowInputsStore, @@ -84,14 +84,26 @@ }) -{#if $selectedId?.startsWith('settings')} +{#if selectionManager && selectionManager.selectedIds.length > 1} +
+

Multiple Selection

+

{selectionManager.selectedIds.length} nodes selected

+
+ {#each selectionManager.selectedIds as nodeId} +
+ {nodeId} +
+ {/each} +
+
+{:else if selectionManager.getSelectedId()?.startsWith('settings')} -{:else if $selectedId === 'Input'} +{:else if selectionManager.getSelectedId() === 'Input'} { - $selectedId = 'triggers' + selectionManager.selectId('Trigger') handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind) showCaptureHint.set(true) }} @@ -99,22 +111,22 @@ {onTestFlow} {previewOpen} /> -{:else if $selectedId === 'Result'} +{:else if selectionManager.getSelectedId() === 'Result'} -{:else if $selectedId === 'constants'} +{:else if selectionManager.getSelectedId() === 'constants'} -{:else if $selectedId === 'failure'} +{:else if selectionManager.getSelectedId() === 'failure'} -{:else if $selectedId === 'preprocessor'} +{:else if selectionManager.getSelectedId() === 'preprocessor'} -{:else if $selectedId === 'triggers'} +{:else if selectionManager.getSelectedId() === 'Trigger'} { await insertNewPreprocessorModule(flowStore, flowStateStore, { language: 'bun' }) - $selectedId = 'preprocessor' + selectionManager.selectId('preprocessor') }} on:updateSchema={(e) => { const { payloadData, redirect } = e.detail @@ -122,7 +134,7 @@ previewArgs.val = JSON.parse(JSON.stringify(payloadData)) } if (redirect) { - $selectedId = 'Input' + selectionManager.selectId('Input') $flowInputEditorState.selectedTab = 'captures' $flowInputEditorState.payloadData = payloadData } @@ -141,7 +153,7 @@ schema={flowStore.val.schema} {onDeployTrigger} /> -{:else if $selectedId.startsWith('subflow:')} +{:else if selectionManager.getSelectedId()?.startsWith('subflow:')}
Selected step is witin an expanded subflow and is not directly editable in the flow editor
@@ -150,7 +162,7 @@ {#if dup}
There are duplicate modules in the flow at id: {dup}
{:else} - {#key $selectedId} + {#key selectionManager.getSelectedId()} {#each flowStore.val.value.modules as flowModule, index (flowModule.id ?? index)} { - $selectedId && untrack(() => onSelectedIdChange()) + selectionManager.getSelectedId() && untrack(() => onSelectedIdChange()) }) let parentLoop = $derived( flowStore.val && flowModule ? checkIfParentLoop(flowStore.val, flowModule.id) : undefined @@ -404,7 +404,7 @@ on:createScriptFromInlineScript={async () => { const [module, state] = await createScriptFromInlineScript( flowModule, - $selectedId, + selectionManager.getSelectedId()!, flowStateStore.val[flowModule.id].schema, $pathStore ) @@ -468,7 +468,7 @@ automaticLayout={true} cmdEnterAction={async () => { selected = 'test' - if ($selectedId == flowModule.id) { + if (selectionManager.getSelectedId() == flowModule.id) { if (flowModule.value.type === 'rawscript' && editor) { flowModule.value.content = editor.getCode() } @@ -578,7 +578,8 @@ class="px-2 xl:px-4" bind:this={inputTransformSchemaForm} pickableProperties={stepPropPicker.pickableProperties} - schema={flowStateStore.val[$selectedId]?.schema ?? {}} + schema={flowStateStore.val[selectionManager.getSelectedId()!]?.schema ?? + {}} previousModuleId={previousModule?.id} bind:args={ () => { @@ -609,7 +610,7 @@ bind:this={modulePreview} mod={flowModule} {noEditor} - schema={flowStateStore.val[$selectedId]?.schema ?? {}} + schema={flowStateStore.val[selectionManager.getSelectedId()!]?.schema ?? {}} bind:testJob bind:testIsLoading bind:scriptProgress @@ -623,7 +624,7 @@ active={flowModule.retry !== undefined} label="Retries" /> - {#if !$selectedId.includes('failure')} + {#if !selectionManager.getSelectedId()?.includes('failure')} { - $selectedId = 'settings-same-worker' + selectionManager.selectId('settings-same-worker') }} > Set shared directory in the flow settings diff --git a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte index e9e15120fc..c0d4b10cb2 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSleep.svelte @@ -20,7 +20,7 @@ let { flowModule = $bindable(), previousModuleId }: Props = $props() - const { selectedId, flowStore, flowStateStore, previewArgs } = + const { selectionManager, flowStore, flowStateStore, previewArgs } = getContext('FlowEditorContext') let schema = $state(emptySchema()) schema.properties['sleep'] = { @@ -41,7 +41,7 @@ ) ) - const result = flowStateStore.val[$selectedId]?.previewResult ?? {} + const result = flowStateStore.val[selectionManager.getSelectedId()!]?.previewResult ?? {} let isSleepEnabled = $derived(Boolean(flowModule.sleep)) diff --git a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte index fffc702a22..20532927e0 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleSuspend.svelte @@ -18,8 +18,8 @@ import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte' import AddProperty from '$lib/components/schema/AddProperty.svelte' - const { selectedId, flowStateStore } = getContext('FlowEditorContext') - const result = flowStateStore.val[$selectedId]?.previewResult ?? {} + const { selectionManager, flowStateStore } = getContext('FlowEditorContext') + const result = flowStateStore.val[selectionManager.getSelectedId()!]?.previewResult ?? {} let editor: SimpleEditor | undefined = $state(undefined) interface Props { diff --git a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte index 21b689206c..d724f241e8 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWorkerTagSelect.svelte @@ -17,7 +17,7 @@ noLabel?: boolean } = $props() - const { flowStore, selectedId } = getContext('FlowEditorContext') + const { flowStore, selectionManager } = getContext('FlowEditorContext') const dispatch = createEventDispatcher() loadWorkerGroups() @@ -44,7 +44,7 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index 8af071e7f5..9224a564e6 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -23,7 +23,7 @@ import { formatCron } from '$lib/utils' import AgentToolWrapper from './AgentToolWrapper.svelte' - const { selectedId, flowStateStore } = getContext('FlowEditorContext') + const { selectionManager, flowStateStore } = getContext('FlowEditorContext') const { triggersState, triggersCount } = getContext('TriggerContext') @@ -113,7 +113,7 @@ } -{#if flowModule.id === $selectedId} +{#if flowModule.id === selectionManager.getSelectedId()!} {#if flowModule.value.type === 'forloopflow'} {:else if flowModule.value.type === 'whileloopflow'} @@ -123,13 +123,13 @@ {:else if flowModule.value.type === 'branchall'} {:else if flowModule.value.type === 'identity'} - {#if $selectedId == 'failure'} + {#if selectionManager.getSelectedId() == 'failure'}
If defined, the error handler will take the error as input.
- {:else if $selectedId == 'preprocessor'} + {:else if selectionManager.getSelectedId() == 'preprocessor'}
{ const { path, summary, kind, hash } = detail createModuleFromScript(path, summary, kind, hash) @@ -187,8 +187,8 @@ flowModule = module flowStateStore.val[module.id] = state }} - failureModule={$selectedId === 'failure'} - preprocessorModule={$selectedId === 'preprocessor'} + failureModule={selectionManager.getSelectedId() === 'failure'} + preprocessorModule={selectionManager.getSelectedId() === 'preprocessor'} /> {/if} {:else if flowModule.value.type === 'rawscript' || flowModule.value.type === 'script' || flowModule.value.type === 'flow' || flowModule.value.type === 'aiagent'} @@ -197,8 +197,8 @@ bind:flowModule {parentModule} {previousModule} - failureModule={$selectedId === 'failure'} - preprocessorModule={$selectedId === 'preprocessor'} + failureModule={selectionManager.getSelectedId() === 'failure'} + preprocessorModule={selectionManager.getSelectedId() === 'preprocessor'} {scriptKind} {scriptTemplate} {enableAi} @@ -225,7 +225,7 @@ /> {/each} {:else if flowModule.value.type === 'branchone'} - {#if $selectedId === `${flowModule?.id}-branch-default`} + {#if selectionManager.getSelectedId() === `${flowModule?.id}-branch-default`}

Default branch

Nothing to configure, this is the default branch if none of the predicates are met. @@ -247,7 +247,7 @@ {/each} {/if} {#each flowModule.value.branches as branch, branchIndex (branchIndex)} - {#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`} + {#if selectionManager.getSelectedId() === `${flowModule?.id}-branch-${branchIndex}`} {:else} {#each branch.modules as _, index} @@ -295,7 +295,7 @@ {/each} {:else if flowModule.value.type === 'aiagent'} {#each flowModule.value.tools as tool, toolIndex (toolIndex)} - {#if $selectedId === tool.id} + {#if selectionManager.getSelectedId() === tool.id} ('FlowEditorContext') + const { selectionManager } = getContext('FlowEditorContext') let flowPreviewContent: FlowPreviewContent | undefined = $state(undefined) let preventEscape = $state(false) @@ -70,7 +70,7 @@ $state('timeline') let upToDisabled = $derived.by(() => { - const upToSelected = upToId ?? $selectedId + const upToSelected = upToId ?? selectionManager.getSelectedId() return ( upToSelected == undefined || [ @@ -92,7 +92,7 @@ 'constants', 'Result', 'Input', - 'triggers' + 'Trigger' ].includes(upToSelected) || upToSelected?.includes('branch') || aiChatManager.flowAiChatHelpers?.getModuleAction(upToSelected) === 'removed' @@ -144,8 +144,8 @@ dropdownItems={!upToDisabled ? [ { - label: 'Test up to ' + $selectedId, - onClick: () => testUpTo($selectedId, true) + label: 'Test up to ' + selectionManager.getSelectedId(), + onClick: () => testUpTo(selectionManager.getSelectedId(), true) } ] : undefined} diff --git a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte index 2a80a34f37..c9d681da50 100644 --- a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte @@ -29,7 +29,7 @@ generateStep: { moduleId: string; instructions: string; lang: ScriptLang } }>() - const { selectedId, flowStateStore, flowStore } = + const { selectionManager, flowStateStore, flowStore } = getContext('FlowEditorContext') async function insertFailureModule( @@ -50,7 +50,7 @@ }) } - $selectedId = 'failure' + selectionManager.selectId('failure') refreshStateStore(flowStore) } @@ -70,10 +70,10 @@ aiModuleActionToTextColor(action) )} id="flow-editor-error-handler" - selected={$selectedId?.includes('failure')} + selected={selectionManager.getSelectedId()?.includes('failure')} onClick={() => { if (flowStore.val?.value?.failure_module) { - $selectedId = 'failure' + selectionManager.selectId('failure') } }} > @@ -95,7 +95,7 @@ class="ml-1" onclick={() => { flowStore.val.value.failure_module = undefined - $selectedId = 'settings-metadata' + selectionManager.selectId('settings-metadata') }} > diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index e3ab7afe4f..d480240a63 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -105,7 +105,7 @@ let flowTutorials: FlowTutorials | undefined = $state(undefined) - const { customUi, selectedId, moving, history, flowStateStore, flowStore, pathStore } = + const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } = getContext('FlowEditorContext') const { triggersCount, triggersState } = getContext('TriggerContext') @@ -238,9 +238,9 @@ let allIds = dfs(flowStore.val.value.modules, (mod) => mod.id) if (allIds.length > 1) { const idx = allIds.indexOf(id) - $selectedId = idx == 0 ? allIds[0] : allIds[idx - 1] + selectionManager.selectId(idx == 0 ? allIds[0] : allIds[idx - 1]) } else { - $selectedId = 'settings-metadata' + selectionManager.selectId('settings-metadata') } } } @@ -431,7 +431,7 @@ flowStore.val.value.notes = newNotes }} preprocessorModule={flowStore.val.value?.preprocessor_module} - {selectedId} + {selectionManager} {workspace} editMode {onTestUpTo} @@ -450,7 +450,7 @@ const cb = () => { push(history, flowStore.val) if (id === 'preprocessor') { - $selectedId = 'Input' + selectionManager.selectId('Input') flowStore.val.value.preprocessor_module = undefined } else { selectNextId(id) @@ -509,7 +509,7 @@ let [removedModule] = originalModules.splice(indexToRemove, 1) targetModules.splice(detail.index, 0, removedModule) - $selectedId = removedModule.id + selectionManager.selectId(removedModule.id) $moving = undefined } else { if (detail.isPreprocessor) { @@ -519,7 +519,7 @@ detail.inlineScript, detail.script ) - $selectedId = 'preprocessor' + selectionManager.selectId('preprocessor') if (detail.inlineScript?.instructions) { dispatch('generateStep', { @@ -546,7 +546,7 @@ toolKind ) const id = targetModules[index].id - $selectedId = id + selectionManager.selectId(id) if (detail.inlineScript?.instructions) { dispatch('generateStep', { @@ -631,13 +631,13 @@ flowStateStore.val[newId] = flowStateStore.val[id] delete flowStateStore.val[id] refreshStateStore(flowStore) - $selectedId = newId + selectionManager.selectId(newId) }} onDeleteBranch={async ({ id, index }) => { if (id) { await removeBranch(id, index) refreshStateStore(flowStore) - $selectedId = id + selectionManager.selectId(id) } }} onMove={(id) => { @@ -658,6 +658,7 @@ {onOpenPreview} {onHideJobStatus} exitNoteMode={() => (noteMode = false)} + multiSelectEnabled />
diff --git a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte index ef8e3f7ab6..8934771859 100644 --- a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte +++ b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte @@ -32,7 +32,7 @@ disableAi }: Props = $props() - const { selectedId, flowStore } = getContext('FlowEditorContext') + const { selectionManager, flowStore } = getContext('FlowEditorContext')
@@ -41,10 +41,10 @@ unifiedSize="sm" wrapperClasses="min-w-36" startIcon={{ icon: Settings }} - selected={$selectedId?.startsWith('settings')} + selected={selectionManager.getSelectedId()?.startsWith('settings')} variant="default" title="Settings" - onClick={() => ($selectedId = 'settings')} + onClick={() => selectionManager.selectId('settings')} > Settings {#if flowStore.val.value.same_worker} @@ -64,10 +64,10 @@ wrapperClasses="h-full" unifiedSize="sm" startIcon={{ icon: DollarSign }} - selected={$selectedId === 'constants'} + selected={selectionManager.getSelectedId() === 'constants'} variant="default" iconOnly - onClick={() => ($selectedId = 'constants')} + onClick={() => selectionManager.selectId('constants')} /> {#snippet text()} Static inputs diff --git a/frontend/src/lib/components/flows/map/MapItem.svelte b/frontend/src/lib/components/flows/map/MapItem.svelte index 26df6f1803..e04c7f069b 100644 --- a/frontend/src/lib/components/flows/map/MapItem.svelte +++ b/frontend/src/lib/components/flows/map/MapItem.svelte @@ -2,7 +2,6 @@ import { Button } from '$lib/components/common' import type { FlowModule, Job } from '$lib/gen' import { createEventDispatcher, getContext } from 'svelte' - import type { Writable } from 'svelte/store' import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte' import FlowModuleIcon from '../FlowModuleIcon.svelte' import { prettyLanguage } from '$lib/common' @@ -17,6 +16,7 @@ import { twMerge } from 'tailwind-merge' import type { FlowNodeState } from '$lib/components/graph' import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core' + import { getGraphContext } from '$lib/components/graph/graphContext' interface Props { moduleId: string @@ -74,9 +74,7 @@ maximizeSubflow }: Props = $props() - const { selectedId } = getContext<{ - selectedId: Writable - }>('FlowGraphContext') + const { selectionManager } = getGraphContext() const { flowStore } = getContext('FlowEditorContext') || {} @@ -88,7 +86,9 @@ }>() let itemProps = $derived({ - selected: $selectedId === mod.id, + selected: + selectionManager?.getSelectedId() === mod.id || + (selectionManager && selectionManager.selectedIds.includes(mod.id)), retry: mod.retry?.constant != undefined || mod.retry?.exponential != undefined, earlyStop: mod.stop_after_if != undefined || mod.stop_after_all_iters_if != undefined, skip: Boolean(mod.skip_if), diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 3fc87ba272..1c69c46fea 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -1,4 +1,4 @@ -import type { Job, OpenFlow } from '$lib/gen' +import type { FlowNote, Job, OpenFlow } from '$lib/gen' import type { History } from '$lib/history.svelte' import type { Writable } from 'svelte/store' import type ScriptEditorDrawer from './content/ScriptEditorDrawer.svelte' @@ -15,16 +15,7 @@ import type ResourceEditorDrawer from '../ResourceEditorDrawer.svelte' import type { ModulesTestStates } from '../modulesTest.svelte' import type { ButtonProp } from '$lib/components/DiffEditor.svelte' -import type { NoteColor } from '../graph/noteColors' - -// Type for flow notes stored in the UI field -export type Note = { - id: string - text: string - position: { x: number; y: number } - size: { width: number; height: number } - color: NoteColor -} +import type { SelectionManager } from '../graph/selectionUtils.svelte' export type FlowInput = Record< string, @@ -47,7 +38,7 @@ export type ExtendedOpenFlow = OpenFlow & { visible_to_runner_only?: boolean on_behalf_of_email?: string ui?: { - notes?: Note[] + notes?: FlowNote[] } } @@ -83,7 +74,7 @@ export type CurrentEditor = | undefined export type FlowEditorContext = { - selectedId: Writable + selectionManager: SelectionManager currentEditor: Writable moving: Writable<{ id: string } | undefined> previewArgs: StateStore> diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index a16a222802..a41fd06d19 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -1,9 +1,8 @@ {#if insertable} @@ -674,7 +723,7 @@ {/if}
{#if graph?.error} @@ -703,6 +752,9 @@ { document.dispatchEvent(new Event('focus')) + if (actualSelectionManager.mode === 'normal') { + actualSelectionManager.clearSelection() + } }} onnodedragstop={(event) => { const node = event.targetNode @@ -736,12 +788,46 @@ {/if} + + actualSelectionManager.selectedIds.includes(node.id) + )} + /> + + + actualSelectionManager.selectNodes(nodeIds, addToExisting, modules, nodes)} + {nodes} + /> + {#if leftHeader}
{@render leftHeader()}
{:else} + {#if multiSelectEnabled} +
+ { + actualSelectionManager.mode = + actualSelectionManager.mode === 'normal' ? 'rect-select' : 'normal' + }} + title="Toggle rectangle selection" + class={actualSelectionManager.mode === 'rect-select' + ? 'text-accent !bg-surface-selected' + : ''} + > + + + {#if actualSelectionManager.selectedIds.length > 0} + {actualSelectionManager.selectedIds.length} selected + {/if} +
+ {/if} {#if download} { diff --git a/frontend/src/lib/components/graph/SelectionBoundingBox.svelte b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte new file mode 100644 index 0000000000..efef1f2e19 --- /dev/null +++ b/frontend/src/lib/components/graph/SelectionBoundingBox.svelte @@ -0,0 +1,53 @@ + + +{#if bounds() && selectedNodes.length > 1} + {@const currentBounds = bounds()!} +
+
+ {selectedNodes.length} nodes selected +
+
+{/if} \ No newline at end of file diff --git a/frontend/src/lib/components/graph/SelectionTool.svelte b/frontend/src/lib/components/graph/SelectionTool.svelte new file mode 100644 index 0000000000..99ef277f1f --- /dev/null +++ b/frontend/src/lib/components/graph/SelectionTool.svelte @@ -0,0 +1,180 @@ + + +{#if selectionMode === 'rect-select'} +
+ + {#if previewNote && isDrawing} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts new file mode 100644 index 0000000000..99270a763a --- /dev/null +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -0,0 +1,15 @@ +import { getContext, setContext } from 'svelte' +import type { SelectionManager } from './selectionUtils.svelte' +import type { Writable } from 'svelte/store' + +type GraphContext = { + selectionManager: SelectionManager + useDataflow: Writable + showAssets: Writable +} + +const graphContextKey = 'FlowGraphContext' + +//TODO: use https://svelte.dev/docs/svelte/context#Type-safe-context after migrating svelte 5 to latest version +export const getGraphContext = () => getContext(graphContextKey) +export const setGraphContext = (context: GraphContext) => setContext(graphContextKey, context) diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 568f16c0a0..3010a94325 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -2,8 +2,6 @@ import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte' import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabel } from '@xyflow/svelte' import { ClipboardCopy, Hourglass } from 'lucide-svelte' - import { getContext } from 'svelte' - import type { Writable } from 'svelte/store' import type { GraphEventHandlers } from '../../graphBuilder.svelte' import { getStraightLinePath } from '../utils' import { twMerge } from 'tailwind-merge' @@ -13,11 +11,9 @@ import type { Job } from '$lib/gen' import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' + import { getGraphContext } from '../../graphContext' - const { useDataflow, showAssets } = getContext<{ - useDataflow: Writable - showAssets?: Writable - }>('FlowGraphContext') + const { useDataflow, showAssets } = getGraphContext() let { // id, diff --git a/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte index 47a099b7c8..0f622bc65c 100644 --- a/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/EmptyEdge.svelte @@ -1,8 +1,7 @@ @@ -322,7 +319,7 @@