feat: flow group nodes with collapsible groups (#8075)

* feat: add flow group nodes core infrastructure

Add group data model (start_id/end_id boundary pairs), GroupEditor for
CRUD operations, groupDetectionUtils for membership computation and
validation, GroupedModulesProxy for reactive sync, and compound layout
support. Update openflow.openapi.yaml with group schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add group UI components and rendering

Add GroupOverlay with bounding box and z-ordering, GroupHeader with
StepCountTab and ellipsis menu, GroupNodeCard, GroupNoteArea for inline
markdown notes, CollapsedGroupNode/CollapsedSubflowNode for collapsed
rendering, GroupEndNode/GroupHeadNode boundary markers, and group
actions in NodeContextMenu and SelectionBoundingBox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: integrate groups into flow graph, builder, and existing components

Wire group support into FlowGraphV2 (overlays, collapsed rendering,
group-aware layout), graphBuilder (GroupedModule tree, container
collapse/expand, group boundary nodes), BaseEdge (drop targets for
group operations), ModuleNode (collapsed container rendering), and
flow map components (schema item grouping). Remove SubflowBound in
favor of CollapsedSubflowNode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove banned $bindable(default) pattern and dead ternary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: decouple collapse state from grouped module tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: pass groups prop to FlowGraphV2 and use GroupDisplayState via graphContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove group membership system, compute nesting depth from visual bounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify GroupOverlay bounds, remove unused headerY and showNotes prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: populate innerNodeIds for expanded subflow overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove expanded subflow overlay feature for separate PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: flatten groups in getContainerModules to prevent crash on collapsed containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add drag-to-move support for group nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: derive group boundaries from expanded membership to prevent splitting existing groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: catch group validation errors and display as flow graph alert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for group validation in buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject virtual nodes (Input, Result, Trigger) from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add virtual node rejection tests for buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude preprocessor and failure module from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable Create group button when preprocessor is selected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject selection entirely when it contains excluded nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove unnecessary excludeIds from buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove debug console.log from FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use cross-browser CSS grid trick for group summary input auto-sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide group boundary edges and reformat GroupNoteArea

Hide edges between group header and first node, and between last node
and group-end, keeping them in the DOM but visually hidden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: stop FlowGraphV2 from reading groups via groupEditorContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show module previews with status, selection, and suspend popover in collapsed groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract collapsible implicit containers to separate branch

Remove collapse/expand functionality for implicit containers (forloops,
while loops, branches) from this branch. Backed up as
collapsible-implicit-containers-backup for later rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use original reactive modules for graph node data instead of proxy snapshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent node loss when moving into forloop inside a group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace GroupedModule proxy with structure-only FlowStructureNode tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use "group-" prefix for group IDs instead of "note-"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update group boundaries when renaming a module ID

When a module at a group boundary (start_id or end_id) is renamed,
the group definitions now get updated before the reactive rebuild,
preventing stale references that would break the flow structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update graph layout when removing a group note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add opaque background behind test run button to prevent see-through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect and reject duplicate group IDs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify group creation validation with early marker normalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use $state.raw in MiniFlowGraph to avoid xyflow performance warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address code review feedback

- Revert backend traverse_modules change (not part of this feature)
- Use Map for node lookup in GroupOverlay (O(1) vs O(n) per group)
- Extract computeNodeExtraSpace to nodeExtraSpace.ts for testability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address PR review feedback

- Compute group depths from structure tree O(n) instead of O(n²) bounds comparison
- Remove unnecessary $derived(groups) in GroupOverlay
- Remove unused collapsed field from container types in OpenAPI spec
- Use NODE.width constant in GroupNodeCard instead of hardcoded 275px
- Add comment explaining intentional stale preservation in rebuild()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve flow groups during dependency job re-serialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve Svelte state_referenced_locally warnings in GroupHeader and FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show subflow groups when expanding a subflow in the graph

- Store both modules and groups when expanding a subflow
- Pass groups to buildStructureTree so group nodes render
- Include subflow groups in overlay rendering and collapse tracking
- Clone modules for prefix rewriting to avoid state_unsafe_mutation
- Register expanded subflow modules in moduleMap before prefix rewriting
- Disable group editing in expanded subflows and read-only views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore accidentally removed code from main

- Restore subflowBound selection handling in selectionUtils
- Restore comments in SelectionBoundingBox
- Restore deletable={false} in FirstStepInputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove redundant adjacency check from MoveManager

The disableMoveIds check already prevents all invalid drop targets,
making the adjacencySourceId/adjacencyTargetId fields unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after OpenAPI schema change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate cli skills after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include groups in view_graph localStorage state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: centralize canCreateGroup and replace group note with group creation

- Add canCreateGroup StateStore to GroupEditorContext, computed in FlowGraphV2
- Replace "Create group note" with "Create group" in FlowSelectionPanel
- Remove "Add note" from selection bounding box dropdown
- Remove unused NodeContextMenu component
- Wire createGroup through FlowModuleSchemaMap → FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject groups spanning parallel branches and surface ill-formed group errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure modules appears before groups in YAML export

Svelte 5's $state proxy registers groups as a tracked property before
it's explicitly set, causing it to appear before modules in Object.keys
iteration. Reorder the value object at export time for readable YAML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address second round of PR review feedback

- Add comment explaining duplicateMultiple bypasses structure tree
- Add warning log for inverted ranges in computeGroupModuleIds
- Use NODE.width constant in CollapsedGroupNode instead of hardcoded 275px
- Simplify redundant condition in getGroupsEmptiedBy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove stored group ID, derive ephemeral key from start_id:end_id

Groups no longer store an `id` field. Instead, a `groupKey(g)` helper
derives an ephemeral key from `${start_id}:${end_id}` at read time.
This simplifies the schema while preserving all runtime functionality.

When boundaries shift (module deletion), runtime state (collapse,
note heights) is remapped to the new key via GroupDisplayState.remapGroupKey.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add note button, save/cancel hints, and rename collapsed_by_default to autocollapse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: propagate selection from collapsed group badges to external listeners

Pass eventHandlers to GroupModuleIcons so clicking a module badge
calls both selectionManager.selectId (visual highlight) and
eventHandlers.select (side panel propagation via onSelect).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide In/Out popovers and actions during click-to-move

Replace isDragging with isMoving derived that covers both drag-move
and click-move states, disabling popovers, delete button, and test
run button during any move operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-03-24 16:47:33 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8cfaa91d43
commit 81eb446eee
58 changed files with 3891 additions and 624 deletions
+6 -5
View File
@@ -286,16 +286,17 @@ pub struct FlowData {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowNotes {
pub struct FlowExtras {
pub notes: Option<Box<RawValue>>,
pub groups: Option<Box<RawValue>>,
}
impl FlowData {
pub fn notes(&self) -> Option<FlowNotes> {
serde_json::from_str::<FlowNotes>(self.raw_flow.get())
pub fn extras(&self) -> Option<FlowExtras> {
serde_json::from_str::<FlowExtras>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse notes into FlowNotes: {}", e);
error::Error::internal_err(format!("Failed to parse notes into FlowNotes: {}", e))
tracing::error!("Failed to parse flow extras: {}", e);
error::Error::internal_err(format!("Failed to parse flow extras: {}", e))
})
.ok()
}
@@ -427,13 +427,13 @@ pub async fn handle_flow_dependency_job(
// `JobKind::FlowDependencies` job store either:
// - A saved flow version `id` in the `script_hash` column.
// - Preview raw flow in the `queue` or `job` table.
let (mut flow, notes) = match job.runnable_id {
let (mut flow, extras) = match job.runnable_id {
Some(ScriptHash(id)) => {
let flow = cache::flow::fetch_version(db, id).await?;
(flow.value().clone(), flow.notes())
(flow.value().clone(), flow.extras())
}
_ => match preview_data {
Some(RawData::Flow(data)) => (data.value().clone(), data.notes()),
Some(RawData::Flow(data)) => (data.value().clone(), data.extras()),
_ => return Err(Error::internal_err("expected script hash")),
},
};
@@ -528,18 +528,22 @@ pub async fn handle_flow_dependency_job(
}
#[derive(Debug, Clone, Serialize)]
struct FlowValueWithNotes<'a> {
struct FlowValueWithExtras<'a> {
#[serde(flatten)]
value: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<Box<RawValue>>, // TODO: Make this a Vec<FlowNote>
notes: Option<Box<RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
groups: Option<Box<RawValue>>,
}
let new_flow_value = Json(
serde_json::value::to_raw_value(&FlowValueWithNotes {
serde_json::value::to_raw_value(&FlowValueWithExtras {
value: &flow,
notes: notes.and_then(|n| n.notes).map(|n| n.into()),
notes: extras.as_ref().and_then(|e| e.notes.clone()),
groups: extras.as_ref().and_then(|e| e.groups.clone()),
})
.map_err(to_anyhow)?,
);
File diff suppressed because one or more lines are too long
+6
View File
@@ -34,6 +34,7 @@
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import { SelectionManager } from './graph/selectionUtils.svelte'
import { NoteEditor, setNoteEditorContext } from './graph/noteEditor.svelte'
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { dfs } from './flows/dfs'
import { loadSchemaFromModule } from './flows/flowInfers'
import { CornerDownLeft, Play } from 'lucide-svelte'
@@ -561,6 +562,11 @@
})
setNoteEditorContext(noteEditor)
// Set up GroupEditor context for group editing capabilities
const groupEditor = new GroupEditor(flowStore)
let canCreateGroup = $state({ val: false })
setGroupEditorContext(groupEditor, canCreateGroup)
let lastSent: OpenFlow | undefined = undefined
function updateFlow(flow: OpenFlow) {
if (lockChanges) {
@@ -85,7 +85,6 @@
>
<FlowModuleSchemaItemViewer
onclick={handleClick}
deletable={false}
id={mod.id}
label={mod.summary ||
(`path` in mod.value ? mod.value.path : undefined) ||
@@ -43,6 +43,7 @@
import { SelectionManager } from './graph/selectionUtils.svelte'
import { NoteEditor } from './graph/noteEditor.svelte'
import { setNoteEditorContext } from './graph/noteEditor.svelte'
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
Calendar,
@@ -672,6 +673,11 @@
)
setNoteEditorContext(noteEditor)
// Set up GroupEditor context for group editing capabilities
const groupEditor = new GroupEditor(flowStore)
let canCreateGroup = $state({ val: false })
setGroupEditorContext(groupEditor, canCreateGroup)
setContext(
'FlowGraphAssetContext',
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
@@ -140,6 +140,7 @@
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
@@ -171,6 +172,7 @@
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
@@ -201,6 +203,7 @@
<FlowGraphV2
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
@@ -9,24 +9,23 @@
import { dfs } from './flows/dfs'
import { workspaceStore } from '$lib/stores'
interface Props {
flow: {
summary: string
description?: string
value: FlowValue
schema?: any
path?: string
};
overflowAuto?: boolean;
noSide?: boolean;
download?: boolean;
noGraph?: boolean;
triggerNode?: boolean;
stepDetail?: FlowModule | string | undefined;
workspace?: string | undefined;
minHeight?: number;
noBorder?: boolean;
summary: string
description?: string
value: FlowValue
schema?: any
path?: string
}
overflowAuto?: boolean
noSide?: boolean
download?: boolean
noGraph?: boolean
triggerNode?: boolean
stepDetail?: FlowModule | string | undefined
workspace?: string | undefined
minHeight?: number
noBorder?: boolean
}
let {
@@ -40,7 +39,7 @@
workspace = $workspaceStore,
minHeight = 400,
noBorder = false
}: Props = $props();
}: Props = $props()
const dispatch = createEventDispatcher()
</script>
@@ -64,6 +63,7 @@
failureModule={flow?.value?.failure_module}
preprocessorModule={flow?.value?.preprocessor_module}
notes={flow?.value?.notes}
groups={flow?.value?.groups}
onSelect={(nodeId) => {
if (nodeId === 'Trigger') {
dispatch('triggerDetail')
@@ -236,7 +236,9 @@
})
let jobResults: any[] = $state(
untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? []
untrack(() => flowJobIds)?.flowJobs?.map(
(x, id) => `iter #${id + 1} not loaded by frontend yet`
) ?? []
)
function asWorkflowStatus(x: any): Record<string, WorkflowStatus> {
@@ -255,7 +257,7 @@
let retry_selected = $state('')
let timeout: number | undefined = undefined
let expandedSubflows: Record<string, FlowModule[]> = $state({})
let expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }> = $state({})
let selectionManager = new SelectionManager()
@@ -684,10 +686,7 @@
}
})
.catch((e) => {
console.error(
`Could not load inner module duration status for job ${mod.job}`,
e
)
console.error(`Could not load inner module duration status for job ${mod.job}`, e)
})
}
} else {
@@ -1154,7 +1153,7 @@
function allModulesForTimeline(
modules: FlowModule[],
expandedSubflows: Record<string, FlowModule[]>
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
): FlowModuleForTimeline[] {
const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, {
skipToolNodes: true
@@ -1166,7 +1165,7 @@
): FlowModuleForTimeline[] {
return ids.concat(
ids.flatMap(({ id }) => {
let fms = expandedSubflows[id]
let fms = expandedSubflows[id]?.modules
let oid = id.split(':').pop()
if (!oid) {
return []
@@ -1902,6 +1901,7 @@
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules ?? []}
notes={job.raw_flow?.notes ?? []}
groups={job.raw_flow?.groups}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
allowSimplifiedPoll={false}
@@ -1994,7 +1994,9 @@
{#if job.args}
<JobArgs
id={isReplay ? undefined : job.id}
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
workspace={isReplay
? undefined
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={job.args}
/>
{:else}
@@ -2064,14 +2066,18 @@
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
<JobArgs
id={isReplay ? undefined : node.job_id}
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
workspace={isReplay
? undefined
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={node.args}
/>
</div>
{/if}
{#if node.workflow_as_code_status}
<div>
<div class="text-xs text-emphasis font-semibold mb-1">Workflow timeline</div>
<div class="text-xs text-emphasis font-semibold mb-1"
>Workflow timeline</div
>
<WorkflowTimeline
flow_status={asWorkflowStatus(node.workflow_as_code_status)}
flowDone={node.type === 'Success' || node.type === 'Failure'}
@@ -112,6 +112,7 @@
onDeleteSelected={() => flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)}
onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)}
onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)}
onCreateGroup={() => flowModuleSchemaMap?.createGroup(selectionManager.selectedIds)}
{canMoveSelected}
resolvedCount={resolvedModuleIds.length}
/>
@@ -3,8 +3,8 @@
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import { Button } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte'
import { getGroupEditorContext } from '$lib/components/graph/groupEditor.svelte'
import { Group, Move, Copy, Trash2 } from 'lucide-svelte'
import type { Item } from '$lib/utils'
interface Props {
@@ -13,6 +13,7 @@
onDeleteSelected?: () => void
onDuplicateSelected?: () => void
onMoveSelected?: () => void
onCreateGroup?: () => void
canMoveSelected?: boolean
resolvedCount?: number
}
@@ -22,18 +23,14 @@
onDeleteSelected,
onDuplicateSelected,
onMoveSelected,
onCreateGroup,
canMoveSelected = false,
resolvedCount = 0
}: Props = $props()
const noteEditorContext = getNoteEditorContext()
const groupEditorContext = getGroupEditorContext()
function addGroupNote() {
if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) {
// Create the group note
noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds)
}
}
let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false)
let menuItems: Item[] = $derived([
{
@@ -60,11 +57,11 @@
{#snippet action()}
<div class="flex gap-1 items-center">
<Button
onClick={addGroupNote}
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
startIcon={{ icon: StickyNote }}
onClick={() => onCreateGroup?.()}
disabled={!canCreateGroup}
startIcon={{ icon: Group }}
>
Create group note
Create group
</Button>
{#if resolvedCount > 0}
<DropdownV2 items={menuItems} />
@@ -31,9 +31,22 @@
editor?.setCode(code)
}
function validateGroups(groups: { start_id: string; end_id: string }[] | undefined) {
if (!groups) return
const seen = new Set<string>()
for (const g of groups) {
const key = `${g.start_id}:${g.end_id}`
if (seen.has(key)) {
throw new Error(`Duplicate group: '${g.start_id}' → '${g.end_id}'`)
}
seen.add(key)
}
}
function apply() {
try {
const parsed = YAML.parse(code)
validateGroups(parsed.value?.groups)
if (parsed.summary && typeof parsed.summary === 'string') {
flowStore.val.summary = parsed.summary
}
@@ -59,7 +72,7 @@
initialCode = code
sendUserToast('Changes applied')
} catch (e) {
;(sendUserToast('Error parsing yaml: ' + e), true)
sendUserToast('Error parsing yaml: ' + e, true)
}
}
@@ -69,8 +82,12 @@
<Drawer on:open={reload} bind:this={drawer} size="800px">
<DrawerContent title="OpenFlow" on:close={() => drawer?.toggleDrawer()}>
{#snippet actions()}
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}>Reset code</Button>
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}>Apply changes</Button>
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}
>Reset code</Button
>
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}
>Apply changes</Button
>
{/snippet}
{#if flowStore.val}
@@ -192,10 +192,10 @@
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
)
let isDragging = $derived(!!moveManager?.dragging)
let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId)
const outputPickerVisible = $derived(
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isMoving
)
const icon_render = $derived(icon)
@@ -214,7 +214,7 @@
flowStore?.val?.value.failure_module
)}
<Drawer bind:open={editId}>
<DrawerContent title="Edit Step Id {id}" on:close={() => (editId = false)}>
<DrawerContent title="Edit step id {id}" on:close={() => (editId = false)}>
<div>
<IdEditorInput
buttonText="Edit Id "
@@ -285,7 +285,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-base',
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-sm',
colorClasses.bg
)}
style="width: 275px; height: 34px;"
@@ -434,11 +434,9 @@
{label}
{path}
{id}
{deletable}
{bold}
bind:editId
disableEditId={isMultiSelected}
{hover}
{colorClasses}
>
{#snippet icon()}
@@ -484,11 +482,10 @@
{/if}
</div>
{#if deletable && !isDragging}
{#if deletable && !isMoving}
{#if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
<Popover
style="will-change: transform;"
@@ -569,7 +566,7 @@
{/if}
</div>
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging}
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"
@@ -577,7 +574,7 @@
onmouseleave={() => (hover = false)}
>
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
<div class="bg-surface rounded-md" transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="xs"
@@ -2,8 +2,6 @@
import Popover from '$lib/components/Popover.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { FlowNodeColorClasses } from '$lib/components/graph'
import { Pencil } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
let iconWidth: number = $state(0)
@@ -12,11 +10,9 @@
label?: string
path?: string
id?: string
deletable?: boolean
bold?: boolean
editId?: boolean
disableEditId?: boolean
hover?: boolean
colorClasses?: FlowNodeColorClasses
icon?: import('svelte').Snippet
onclick?: () => void
@@ -26,11 +22,9 @@
label = '',
path = '',
id = '',
deletable = false,
bold = false,
editId = $bindable(false),
disableEditId = false,
hover = false,
colorClasses,
icon,
onclick
@@ -87,11 +81,6 @@
}}
>
<span class="max-w-full text-2xs truncate flex items-center">
{#if !disableEditId && (editId || (hover && deletable))}
<span transition:slide={{ axis: 'x', duration: 100 }}>
<Pencil size={10} class="mr-1" />
</span>
{/if}
<span class="max-w-12 truncate">
{id}
</span>
@@ -23,7 +23,7 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { getDependentComponents } from '../flowExplorer'
import { getAllModules, getDependentComponents } from '../flowExplorer'
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
@@ -54,6 +54,18 @@
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import {
GroupedModulesProxy,
type ExtendedOpenFlow
} from '$lib/components/graph/groupedModulesProxy.svelte'
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import {
type FlowStructureNode,
matchStructureNode,
dfsStructure,
findInStructure,
moduleToStructureNode
} from '$lib/components/graph/flowStructure'
interface Props {
sidebarSize?: number | undefined
@@ -125,6 +137,8 @@
// Get NoteEditor context for note position updates
const noteEditorContext = getNoteEditorContext()
const proxy = new GroupedModulesProxy(flowStore as unknown as StateStore<ExtendedOpenFlow>)
const groupDisplayState = new GroupDisplayState(() => flowStore.val.value?.groups ?? [])
$effect(() => {
if (!moveManager.movingModuleId) return
@@ -139,16 +153,13 @@
return () => document.removeEventListener('keydown', onKeyDown, true)
})
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
/** Create a new FlowModule without inserting it into any array */
async function createNewModule(
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
inlineScript?: InlineScript
): Promise<FlowModule> {
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
let state = emptyFlowModuleState()
flowStateStore.val[module.id] = state
@@ -196,6 +207,21 @@
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
return module
}
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
const module = await createNewModule(kind, wsScript, wsFlow, inlineScript)
if (!modules) return [module]
if (toolKind === 'mcpTool') {
@@ -220,7 +246,7 @@
;(modules as AgentTool[]).splice(index, 0, agentTool)
return modules as AgentTool[]
} else {
// Standard FlowModule insertion (existing behavior)
// Standard FlowModule insertion
modules.splice(index, 0, module)
return modules
}
@@ -330,6 +356,13 @@
let deleteCallback: (() => void) | undefined = $state(undefined)
let dependents: Record<string, string[]> = $state({})
/** Confirmation gate for actions that would empty or duplicate groups */
let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] =
$state([])
let affectedGroupsAction: (() => void) | undefined = $state(undefined)
let affectedGroupsCancel: (() => void) | undefined = $state(undefined)
let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete')
let graph: FlowGraphV2 | undefined = $state(undefined)
let noteMode = $state(false)
let diffManager = $derived(getDiffManager())
@@ -361,24 +394,46 @@
}
}
const opts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
for (const id of ids) {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}
}, opts)
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
commit({ removeDuplicates: duplicateGroups.length > 0 })
for (const id of ids) {
removeAtId(flowStore.val.value.modules, id)
delete flowStateStore.val[id]
}
selectionManager.clearSelection()
refreshStateStore(flowStore)
}
if (Object.keys(allDeps).length > 0) {
dependents = allDeps
deleteCallback = cb
const proceed = () => {
if (Object.keys(allDeps).length > 0) {
dependents = allDeps
deleteCallback = cb
} else {
cb()
}
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'delete'
affectedGroupsAction = proceed
} else {
cb()
proceed()
}
}
// Operates directly on the flat module array (not the structure tree).
// Cloned modules are inserted after the originals, intentionally outside any group.
export function duplicateMultiple(ids: string[]) {
const locations = locateModules(ids, flowStore.val.value.modules)
const groups = groupByParent(locations)
@@ -423,6 +478,10 @@
moveManager.toggleMovingMultiple(ids)
}
export function createGroup(ids: string[]) {
graph?.createGroupFromSelection(ids)
}
const dispatch = createEventDispatcher<{
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
change: void
@@ -506,6 +565,40 @@
</div>
{/each}
</ConfirmationModal>
<ConfirmationModal
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
open={affectedGroupsPending.length > 0}
on:confirmed={() => {
affectedGroupsAction?.()
affectedGroupsPending = []
affectedGroupsAction = undefined
affectedGroupsCancel = undefined
}}
on:canceled={() => {
affectedGroupsCancel?.()
affectedGroupsPending = []
affectedGroupsAction = undefined
affectedGroupsCancel = undefined
}}
>
{#if affectedGroupsPending.length === 1}
{@const group = affectedGroupsPending[0]}
<p
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
Are you sure you want to {affectedGroupsActionLabel} the step?</p
>
{:else}
<p>The following groups will be removed (empty or duplicate):</p>
<ul class="list-disc pl-4 mt-1">
{#each affectedGroupsPending as group}
<li>{group.summary || `${group.start_id} ${group.end_id}`}</li>
{/each}
</ul>
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
{/if}
</ConfirmationModal>
</Portal>
<div class="flex flex-col h-full relative -pt-1" bind:clientWidth={flowPaneWidth}>
<div
@@ -542,8 +635,12 @@
{moveManager}
maxHeight={minHeight}
modules={flowStore.val.value.modules}
groupedModules={proxy.items}
groupError={proxy.error}
{groupDisplayState}
{noteMode}
notes={flowStore.val.value.notes}
groups={flowStore.val.value.groups}
preprocessorModule={flowStore.val.value?.preprocessor_module}
failureModule={flowStore.val.value?.failure_module}
currentInputSchema={flowStore.val.schema}
@@ -563,159 +660,274 @@
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
onDelete={(id) => {
dependents = getDependentComponents(id, flowStore.val)
const cb = () => {
push(history, flowStore.val)
if (id === 'preprocessor') {
if (id === 'preprocessor') {
const cb = () => {
push(history, flowStore.val)
selectionManager.selectId('Input')
flowStore.val.value.preprocessor_module = undefined
} else {
selectNextId(id)
removeAtId(flowStore.val.value.modules, id)
refreshStateStore(flowStore)
onDelete?.(id)
delete flowStateStore.val[id]
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
return
}
const dsOpts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}, dsOpts)
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
selectNextId(id)
commit({ removeDuplicates: duplicateGroups.length > 0 })
refreshStateStore(flowStore)
onDelete?.(id)
delete flowStateStore.val[id]
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
const proceed = () => {
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'delete'
affectedGroupsAction = proceed
} else {
cb()
proceed()
}
}}
onInsert={async (detail) => {
{
let originalModules
let targetModules
if (
detail.sourceId == 'Input' ||
detail.targetId == 'Result' ||
detail.kind == 'trigger'
) {
targetModules = flowStore.val.value.modules
if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return
await tick()
// --- MOVE ---
if (moveManager.movingModuleId) {
const movedIds = moveManager.movingIds ?? [moveManager.movingModuleId]
const movingId = moveManager.movingModuleId
let mutated = false
const moveOpts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
let originalModules: FlowStructureNode[] | undefined
let targetModules: FlowStructureNode[] | undefined
if (detail.sourceId == 'Input' || detail.targetId == 'Result') {
targetModules = tree
}
dfsStructure(tree, (node, parentArray) => {
if (matchStructureNode(node, movingId)) originalModules = parentArray
if (detail.branch && matchStructureNode(node, detail.branch.rootId)) {
targetModules = node.branches[detail.branch.branch]?.children
} else if (
matchStructureNode(node, detail.sourceId ?? '') ||
matchStructureNode(node, detail.targetId ?? '')
) {
targetModules = parentArray
}
})
if (!originalModules || !targetModules) return
if (movedIds.length > 1) {
const firstIndex = originalModules.findIndex((m) =>
matchStructureNode(m, movedIds[0])
)
if (firstIndex < 0) return
const removedModules = originalModules.splice(firstIndex, movedIds.length)
let insertIndex = detail.index
if (originalModules === targetModules && firstIndex < detail.index) {
insertIndex -= movedIds.length
}
targetModules.splice(insertIndex, 0, ...removedModules)
} else {
const indexToRemove = originalModules.findIndex((m) =>
matchStructureNode(m, movingId)
)
if (indexToRemove < 0) return
const [removed] = originalModules.splice(indexToRemove, 1)
let insertIndex = detail.index
if (originalModules === targetModules && indexToRemove < detail.index)
insertIndex -= 1
targetModules.splice(insertIndex, 0, removed)
}
mutated = true
}, moveOpts)
if (!mutated) {
moveManager.clearMoving()
return
}
dfs(flowStore.val.value.modules, (mod, modules, branches) => {
if (mod.id == moveManager.movingModuleId) {
originalModules = modules
}
if (detail.branch) {
if (mod.id == detail.branch.rootId) {
targetModules = branches[detail.branch.branch]
}
} else if (mod.id == detail.sourceId || mod.id == detail.targetId) {
targetModules = modules
} else if (mod.id == detail.agentId && mod.value.type === 'aiagent') {
targetModules = mod.value.tools
}
})
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
await tick()
if (moveManager.movingModuleId) {
push(history, flowStore.val)
if (!originalModules || !targetModules) {
moveManager.clearMoving()
return
}
if (moveManager.movingIds && moveManager.movingIds.length > 1) {
// Multi-move: splice out all moving modules from their parent, insert at target
const firstIndex = originalModules.findIndex(
(m) => m.id === moveManager.movingIds?.[0]
)
const removedModules = originalModules.splice(
firstIndex,
moveManager.movingIds.length
)
let insertIndex = detail.index
if (originalModules === targetModules && firstIndex < detail.index) {
insertIndex -= moveManager.movingIds.length
}
targetModules.splice(insertIndex, 0, ...removedModules)
selectionManager.selectByIds(removedModules.map((m) => m.id))
} else {
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
let [removedModule] = originalModules.splice(indexToRemove, 1)
// When moving within the same array, removal shifts subsequent indices down by 1
let insertIndex = detail.index
if (originalModules === targetModules && indexToRemove < detail.index) {
insertIndex -= 1
}
targetModules.splice(insertIndex, 0, removedModule)
selectionManager.selectId(removedModule.id)
}
moveManager.clearMoving()
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const doMove = () => {
push(history, flowStore.val)
commit({ removeDuplicates: duplicateGroups.length > 0 })
if (movedIds.length > 1) {
selectionManager.selectByIds(movedIds)
} else {
if (detail.isPreprocessor) {
await insertNewPreprocessorModule(
flowStore,
flowStateStore,
detail.inlineScript,
detail.script
)
selectionManager.selectId('preprocessor')
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: 'preprocessor',
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
} else {
const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = detail.agentId
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
? (detail.kind as SpecialToolKind)
: 'flowmoduleTool'
: undefined
await insertNewModuleAtIndex(
targetModules,
index,
detail.kind,
detail.script,
detail.flow,
detail.inlineScript,
toolKind
)
const id = targetModules[index].id
selectionManager.selectId(id)
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: id,
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
if (detail.kind == 'trigger') {
await insertNewModuleAtIndex(
targetModules,
index + 1,
'forloop',
undefined,
undefined,
undefined
)
setExpr(targetModules[index + 1], `results.${id}`)
setScheduledPollSchedule(triggersState, triggersCount)
}
if (detail.flow?.path) {
loadLastJob(detail.flow.path, id)
} else if (detail.script?.path) {
loadLastJob(detail.script?.path, id)
}
}
}
if (['branchone', 'branchall'].includes(detail.kind)) {
await addBranch(targetModules[detail.index ?? 0].id)
selectionManager.selectId(movingId)
}
moveManager.clearMoving()
refreshStateStore(flowStore)
dispatch('change')
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'move'
affectedGroupsAction = doMove
affectedGroupsCancel = () => moveManager.clearMoving()
} else {
doMove()
}
return
}
// --- INSERT ---
if (detail.isPreprocessor) {
await insertNewPreprocessorModule(
flowStore,
flowStateStore,
detail.inlineScript,
detail.script
)
selectionManager.selectId('preprocessor')
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: 'preprocessor',
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
refreshStateStore(flowStore)
dispatch('change')
return
}
push(history, flowStore.val)
const isAgentInsert = !!detail.agentId
const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert
? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind)
? (detail.kind as SpecialToolKind)
: 'flowmoduleTool'
: undefined
// Agent tool inserts operate on the FlowModule's tools array directly
if (isAgentInsert) {
const agentMod = getAllModules(flowStore.val.value.modules).find(
(m) => m.id === detail.agentId
)
if (agentMod && (agentMod.value as any).tools) {
const tools = (agentMod.value as any).tools as AgentTool[]
await insertNewModuleAtIndex(
tools,
tools.length,
detail.kind as InsertKind,
detail.script,
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
detail.inlineScript,
toolKind
)
const id = tools[tools.length - 1].id
selectionManager.selectId(id)
}
refreshStateStore(flowStore)
dispatch('change')
return
}
// Regular module insert: create the module, then insert a leaf node via tree mutation
const module = await createNewModule(
detail.kind as InsertKind,
detail.script,
detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined,
detail.inlineScript
)
const index = detail.index ?? 0
const extraModules: FlowModule[] = [module]
// For trigger inserts, also create the forloop module
let loopModule: FlowModule | undefined
if (detail.kind == 'trigger') {
loopModule = await createNewModule('forloop')
setExpr(loopModule, `results.${module.id}`)
extraModules.push(loopModule)
}
proxy.applyTreeMutation(
(tree) => {
// Find target array in the snapshot
let targetArray: FlowStructureNode[] | undefined
if (
detail.sourceId == 'Input' ||
detail.targetId == 'Result' ||
detail.kind == 'trigger'
) {
targetArray = tree
}
dfsStructure(tree, (node, parentArray) => {
if (detail.branch && matchStructureNode(node, detail.branch.rootId)) {
targetArray = node.branches[detail.branch.branch]?.children
} else if (
matchStructureNode(node, detail.sourceId ?? '') ||
matchStructureNode(node, detail.targetId ?? '')
) {
targetArray = parentArray
}
})
if (!targetArray) targetArray = tree
// Insert the structure node (correct kind for containers like branchone/branchall)
targetArray.splice(index, 0, moduleToStructureNode(module))
// For trigger: also insert the forloop node after it
if (loopModule) {
targetArray.splice(index + 1, 0, moduleToStructureNode(loopModule))
}
},
{ extraModules, displayState: groupDisplayState }
)
selectionManager.selectId(module.id)
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: module.id,
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
if (detail.kind == 'trigger') {
setScheduledPollSchedule(triggersState, triggersCount)
}
if (detail.flow?.path) {
loadLastJob(detail.flow.path, module.id)
} else if (detail.script?.path) {
loadLastJob(detail.script?.path, module.id)
}
if (['branchone', 'branchall'].includes(detail.kind)) {
await addBranch(module.id)
}
refreshStateStore(flowStore)
dispatch('change')
}}
onNewBranch={async (id) => {
if (id) {
@@ -761,6 +973,17 @@
mod.id = newId
}
})
const groups = flowStore.val.value.groups
if (groups) {
for (const group of groups) {
if (group.start_id === id) {
group.start_id = newId
}
if (group.end_id === id) {
group.end_id = newId
}
}
}
flowStateStore.val[newId] = flowStateStore.val[id]
delete flowStateStore.val[id]
refreshStateStore(flowStore)
@@ -46,7 +46,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full flex relative rounded-md drop-shadow-base',
'w-full flex relative rounded-md drop-shadow-sm',
colorClasses.bg,
onTop ? 'z-[901]' : '',
className
@@ -72,11 +72,17 @@ export function evalValue(
return v
}
/** Ensure modules comes first and groups last in the value object for readable YAML export. */
function reorderFlowValue(value: ExtendedOpenFlow['value']): ExtendedOpenFlow['value'] {
const { modules, groups, ...rest } = value
return { modules, ...rest, ...(groups != null ? { groups } : {}) }
}
export function filteredContentForExport(flow: ExtendedOpenFlow) {
let o = {
summary: flow.summary,
description: flow.description,
value: flow.value,
value: reorderFlowValue(flow.value),
schema: flow.schema
}
if (flow.dedicated_worker) {
@@ -42,7 +42,12 @@
return { x: n.position.x, y: n.position.y }
}
function computeGhost(moduleId: string, draggedNodeIds: Set<string>, allNodes: Node[], allEdges: Edge[]) {
function computeGhost(
moduleId: string,
draggedNodeIds: Set<string>,
allNodes: Node[],
allEdges: Edge[]
) {
// Use pre-computed draggedNodeIds when available (covers multi-select),
// otherwise fall back to single-module subflow computation.
let sfNodes: Node[]
@@ -111,7 +116,15 @@
zoom: scale
}
return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport }
return {
containerWidth,
containerHeight,
ghostNodes,
ghostEdges,
offsetX,
offsetY,
initialViewport
}
}
let isNearDrop = $derived(moveManager.nearestDropZone != null)
@@ -128,7 +141,8 @@
class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop
? 'bg-surface-accent-primary text-white'
: 'bg-surface text-secondary'}"
style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
style="left: {moveManager.ghostScreenX +
CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
>
<Move size={12} />
</div>
@@ -59,8 +59,22 @@
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import NoteNode from './renderers/nodes/NoteNode.svelte'
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
import NoteTool from './NoteTool.svelte'
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
import GroupOverlay from './GroupOverlay.svelte'
import {
GroupDisplayState,
getGroupEditorContext,
groupKey,
type FlowGroup
} from './groupEditor.svelte'
import { buildStructureTree, computeGroupDepths, type FlowStructureNode } from './flowStructure'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { computeGroupModuleIds } from './groupDetectionUtils'
import { getAllModules } from '../flows/flowExplorer'
import SelectionTool from './SelectionTool.svelte'
import PaneContextMenu from './PaneContextMenu.svelte'
import { SelectionManager } from './selectionUtils.svelte'
@@ -72,6 +86,7 @@
import { compoundLayout } from './compoundLayout'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import { computeNodeExtraSpace } from './nodeExtraSpace'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import { setGraphContext } from './graphContext'
import { computeNoteNodes } from './noteUtils.svelte'
@@ -100,6 +115,8 @@
interface Props {
success?: boolean | undefined
modules?: FlowModule[] | undefined
groupedModules?: FlowStructureNode[]
groupError?: unknown
failureModule?: FlowModule | undefined
preprocessorModule?: FlowModule | undefined
minHeight?: number
@@ -124,7 +141,7 @@
workspace?: string
editMode?: boolean
allowSimplifiedPoll?: boolean
expandedSubflows?: Record<string, FlowModule[]>
expandedSubflows?: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>
isOwner?: boolean
isRunning?: boolean
individualStepTests?: boolean
@@ -133,6 +150,8 @@
suspendStatus?: Record<string, { job: Job; nb: number }>
noteMode?: boolean
notes?: FlowNote[]
groups?: FlowGroup[]
groupDisplayState?: GroupDisplayState
chatInputEnabled?: boolean
multiSelectEnabled?: boolean
onDeleteMultiple?: (ids: string[]) => void
@@ -152,6 +171,7 @@
script?: { path: string; summary: string; hash: string | undefined }
flow?: { path: string; summary: string }
kind: InsertKind
expandGroup?: { groupId: string; position: 'top' | 'bottom' }
}) => Promise<void>
onNewBranch?: (id: string) => Promise<void>
onSelect?: (id: string | FlowModule) => void
@@ -193,6 +213,8 @@
onSelectedIteration = undefined,
success = undefined,
modules = [],
groupedModules: groupedModulesProp = undefined,
groupError = undefined,
failureModule = undefined,
preprocessorModule = undefined,
minHeight = 0,
@@ -232,6 +254,8 @@
flowHasChanged = false,
noteMode = false,
notes = undefined,
groups = undefined,
groupDisplayState: groupDisplayStateProp = undefined,
exitNoteMode = undefined,
onNotePositionUpdate = undefined,
chatInputEnabled = false,
@@ -257,6 +281,9 @@
() => nodes
)
const groupDisplayState =
untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? [])
// Runtime text height tracking for notes (not stored in FlowNote)
let noteTextHeights = $state<Record<string, number>>({})
@@ -264,6 +291,8 @@
let paneContextMenu: PaneContextMenu | undefined = $state(undefined)
let flowContainer: HTMLDivElement | undefined = $state(undefined)
// Hover tracking for group overlay
// Selection manager - create one if not provided
let selectionManager = untrack(() => selectionManagerProp) || new SelectionManager()
const selectedId = $derived(selectionManager.getSelectedId())
@@ -298,7 +327,9 @@
moveManager: untrack(() => moveManager),
clearFlowSelection,
yOffset,
diffManager
diffManager,
getFlowNodes: () => currentGraphNodeDeps,
groupDisplayState
} as any)
if (triggerContext && untrack(() => allowSimplifiedPoll)) {
@@ -332,14 +363,36 @@
type NodeDep = {
id: string
parentIds?: string[]
data?: { assets?: AssetWithAltAccessType[] }
data?: { assets?: AssetWithAltAccessType[]; module?: any }
}
type NodePos = { position: { x: number; y: number } }
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
let lastNodes:
| [NodeDep[], Map<string, { top: number; bottom: number }> | undefined, (NodeDep & NodePos)[]]
| undefined = undefined
let currentGraphNodeDeps: { id: string; parentIds?: string[] }[] = $state([])
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[1]
if (lastResult && deepEqual(nodes, lastNodes?.[0])) {
// Keep canCreateGroup in sync for consumers (SelectionBoundingBox, FlowSelectionPanel, etc.)
const groupEditorCtx = getGroupEditorContext()
$effect(() => {
if (!groupEditorCtx) return
const ids = selectionManager.selectedIds
groupEditorCtx.canCreateGroup.val =
ids.length >= 1 && groupEditorCtx.groupEditor.canCreateGroup(ids, currentGraphNodeDeps)
})
let lastGroupDimensions: Map<string, { width: number; height: number }> | undefined = undefined
function layoutNodes(
nodes: NodeDep[],
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[2]
if (
lastResult &&
deepEqual(nodes, lastNodes?.[0]) &&
deepEqual(nodeExtraSpace, lastNodes?.[1])
) {
console.debug('layoutNodes', 'same nodes')
return lastResult
}
@@ -352,16 +405,23 @@
seenId.push(n.id)
}
// Run recursive compound layout
const { positions, bbox } = compoundLayout(nodes, {
nodeWidth: NODE.width,
nodeHeight: NODE.height,
gapH: NODE.gap.horizontal,
gapV: NODE.gap.vertical
})
// Run recursive compound layout with pre-computed extra space
const layoutResult = compoundLayout(
nodes,
{
nodeWidth: NODE.width,
nodeHeight: NODE.height,
gapH: NODE.gap.horizontal,
gapV: NODE.gap.vertical
},
nodeExtraSpace
)
const { positions, bbox } = layoutResult
lastGroupDimensions = layoutResult.groupDimensions
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
// Center horizontally
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
const newNodes = nodes.map((n) => ({
id: n.id,
position: {
@@ -370,7 +430,7 @@
}
}))
lastNodes = [nodes, newNodes]
lastNodes = [nodes, nodeExtraSpace, newNodes]
return newNodes
}
@@ -414,13 +474,16 @@
},
expandSubflow: async (id: string, path: string) => {
const flow = await FlowService.getFlowByPath({ workspace: workspace, path })
expandedSubflows[id] = flow.value.modules
expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups }
expandedSubflows = expandedSubflows
},
minimizeSubflow: (id: string) => {
delete expandedSubflows[id]
expandedSubflows = expandedSubflows
},
expandGroup: (groupId: string) => {
groupDisplayState.expandGroup(groupId)
},
updateMock: (detail) => {
onUpdateMock?.(detail)
},
@@ -585,17 +648,37 @@
return
}
// console.log('compute')
const graphNodeDeps = Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
data: { assets: (n.data as any).assets, module: (n.data as any).module }
}))
currentGraphNodeDeps = graphNodeDeps
let layoutedNodes = layoutNodes(
Object.values(graph.nodes).map((n) => ({
id: n.id,
parentIds: n.parentIds,
data: { assets: (n.data as any).assets }
}))
)
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] }))
// Pre-compute extra space per node for assets, AI tools, group notes, group headers
const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, {
showAssets: $showAssets ?? true,
showNotes,
notes,
noteTextHeights,
groupDisplayState,
insertable,
flowModuleStates
})
// Layout with extra space baked into sugiyama
let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace)
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => {
const merged = { ...n, ...graph.nodes[n.id] }
// Augment group head nodes with wrapper dimensions from compound layout
if (graph.nodes[n.id]?.type === 'groupHead' && lastGroupDimensions?.has(n.id)) {
const dims = lastGroupDimensions.get(n.id)!
merged.data = { ...merged.data, wrapperWidth: dims.width, wrapperHeight: dims.height }
}
return merged
})
// Compute asset visual nodes (no position remapping)
let assetNodesResult = $showAssets
? computeAssetNodes(
newNodes.map((n) => ({
@@ -605,25 +688,17 @@
}))
)
: undefined
if (assetNodesResult) {
newNodes = newNodes.map((n) => ({
...n,
position: assetNodesResult.newNodePositions[n.id]
}))
}
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
let nodesAfterAITools = newNodes.map((n) => ({
...n,
position: aiToolNodesResult.newNodePositions[n.id]
}))
let finalNodes = [
...nodesAfterAITools,
// Compute AI tool visual nodes (no position remapping)
let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates)
let finalNodes: (Node & NodeLayout)[] = [
...newNodes,
...(assetNodesResult?.newAssetNodes ?? []),
...aiToolNodesResult.toolNodes
]
// Compute note nodes and positions
// Compute note nodes (no position remapping)
let noteNodesResult = showNotes
? computeNoteNodes(
finalNodes.map((n) => ({
@@ -644,14 +719,6 @@
)
: undefined
// Apply note positioning to nodes if notes are enabled
if (noteNodesResult) {
finalNodes = finalNodes.map((n) => ({
...n,
position: noteNodesResult.newNodePositions[n.id] || n.position
}))
}
// update nodes
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
@@ -699,7 +766,10 @@
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode,
note: NoteNode
note: NoteNode,
collapsedGroup: CollapsedGroupNode,
groupHead: GroupHeadNode,
groupEnd: GroupEndNode
} as any
const edgeTypes = {
@@ -735,7 +805,41 @@
let graph = $derived.by(() => {
moduleTracker.counter
effectiveModuleActions
return graphBuilder(
currentGroups
const collapsedGroupIds = new Set(
allGroups
.filter((g) => groupDisplayState.isRuntimeCollapsed(groupKey(g)))
.map((g) => groupKey(g))
)
if (groupError) {
return { nodes: {}, edges: [], error: groupError }
}
// Use provided structure tree (from proxy) or build locally (diff mode / read-only)
let gm: FlowStructureNode[] | undefined = groupedModulesProp
if (!gm) {
const allGroups = groups ?? []
const graphGroups = allGroups.map((g) => ({
...g,
id: groupKey(g),
moduleIds: untrack(() =>
computeGroupModuleIds(g.start_id, g.end_id, getAllModules(effectiveModules ?? []))
)
}))
try {
gm = buildStructureTree(
stateSnapshot(untrack(() => effectiveModules) ?? []) as FlowModule[],
graphGroups
)
} catch (e) {
return { nodes: {}, edges: [], error: e }
}
}
const result = graphBuilder(
gm,
untrack(() => effectiveModules),
{
disableAi,
@@ -767,16 +871,43 @@
untrack(() => selectedId),
simplifiableFlow,
triggerNode ? path : undefined,
expandedSubflows
expandedSubflows,
showNotes,
collapsedGroupIds
)
return { ...result, structureTree: gm }
})
let hideAssetsToggle = $derived(
$showAssets && Object.values(nodes).every((n) => n.type !== 'asset')
)
let hideNotesToggle = $derived(!notes || notes.length === 0)
let hideNotesToggle = $derived(
(!notes || notes.length === 0) && !(groups ?? []).some((g) => g.note != null)
)
let currentGroupDepths = $derived(
'structureTree' in graph && graph.structureTree ? computeGroupDepths(graph.structureTree) : {}
)
// All groups including those from expanded subflows (for overlay rendering)
let allGroups = $derived.by(() => {
const base = groups ?? []
const subflowGroups = Object.values(expandedSubflows).flatMap((sf) => sf.groups ?? [])
return subflowGroups.length > 0 ? [...base, ...subflowGroups] : base
})
// Track groups for re-layout when groups change
let currentGroups = $derived(groups ?? [])
$effect(() => {
;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount]
;[
graph,
allowSimplifiedPoll,
$showAssets,
showNotes,
noteManager.renderCount,
currentGroups,
groupDisplayState.renderCount
]
untrack(async () => {
await updateStores()
})
@@ -893,6 +1024,16 @@
}
}
export function createGroupFromSelection(ids: string[]) {
if (groupEditorCtx?.groupEditor) {
groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps)
tick().then(() => {
clearFlowSelection()
selectionManager.clearSelection()
})
}
}
const modifierKey = isMac() ? 'Meta' : 'Control'
</script>
@@ -909,7 +1050,7 @@
bind:this={flowContainer}
>
{#if graph?.error}
<div class="center-center p-2">
<div class="center-center p-2 mt-20">
<Alert title="Error parsing the flow" type="error" class="max-w-1/2">
{graph.error}
@@ -1008,6 +1149,12 @@
/>
{/if}
<GroupOverlay
allNodes={nodesWithOffset as (Node & { type: string })[]}
groups={allGroups}
groupDepths={currentGroupDepths}
/>
<!-- SelectionTool for handling selection changes and filtering -->
<SelectionTool {selectionManager} clearGraphSelection={clearFlowSelection} />
@@ -1065,7 +1212,7 @@
try {
localStorage.setItem(
'svelvet',
encodeState({ modules, failureModule, preprocessorModule, notes })
encodeState({ modules, failureModule, preprocessorModule, notes, groups })
)
} catch (e) {
console.error('error interacting with local storage', e)
@@ -0,0 +1,158 @@
<script lang="ts">
import { preventDefault, stopPropagation } from 'svelte/legacy'
import { EllipsisVertical, StickyNote, Ungroup } from 'lucide-svelte'
import { NoteColor, NOTE_COLOR_SWATCHES } from './noteColors'
import Toggle from '../Toggle.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import { twMerge } from 'tailwind-merge'
import MoveHandleButton from './MoveHandleButton.svelte'
import type { MoveManager } from './moveManager.svelte'
interface Props {
note: string | undefined | null
color: string | undefined
autocollapse: boolean
visible?: boolean
menuOpen?: boolean
moveManager?: MoveManager
moveModuleId?: string
onMenuOpenChange?: (open: boolean) => void
onAddNote: () => void
onRemoveNote: () => void
onUpdateColor: (color: NoteColor) => void
onUpdateAutocollapse: (value: boolean) => void
onDeleteGroup?: () => void
}
let {
note,
color,
autocollapse,
visible = true,
menuOpen = $bindable(),
moveManager,
moveModuleId,
onMenuOpenChange,
onAddNote,
onRemoveNote,
onUpdateColor,
onUpdateAutocollapse,
onDeleteGroup = undefined
}: Props = $props()
$effect(() => {
onMenuOpenChange?.(menuOpen ?? false)
})
</script>
<div
class="absolute -translate-y-[100%] top-2 right-0 h-7 p-1 min-w-7 flex flex-row gap-2"
style="will-change: transform;"
>
{#if moveManager && moveModuleId}
<MoveHandleButton
{moveManager}
moduleId={moveModuleId}
singleNode
{visible}
onClickMove={() => moveManager.toggleMoving(moveModuleId!)}
/>
{/if}
{#if note == null}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
visible ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
onclick={() => onAddNote()}
title="Add note"
>
<StickyNote size={12} />
</button>
{/if}
<DropdownV2
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
customMenu
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
visible || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
{#snippet menu()}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none py-1"
>
<!-- Color picker -->
<div class="px-4 py-2">
<div class="grid grid-cols-5 gap-1">
{#each Object.values(NoteColor) as c (c)}
<button
class="w-6 h-6 rounded-full hover:scale-110 transition-transform duration-100
{NOTE_COLOR_SWATCHES[c]}
{(color ?? NoteColor.BLUE) === c ? 'ring-2 ring-accent' : 'dark:border-gray-600'}"
onclick={() => onUpdateColor(c)}
title={c.charAt(0).toUpperCase() + c.slice(1)}
></button>
{/each}
</div>
</div>
<!-- Autocollapse toggle -->
<div class="px-4 py-2">
<Toggle
size="xs"
checked={autocollapse}
options={{ right: 'Autocollapse' }}
on:change={(e) => onUpdateAutocollapse(e.detail)}
/>
</div>
<div class="my-1 border-t border-border-light"></div>
<!-- Add / Remove note -->
<button
class="px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm"
onclick={() => {
note == null ? onAddNote() : onRemoveNote()
menuOpen = false
}}
>
<StickyNote size={14} class="shrink-0" />
<p class="truncate grow min-w-0 whitespace-nowrap text-left"
>{note == null ? 'Add note' : 'Remove note'}</p
>
</button>
{#if onDeleteGroup}
<div class="my-1 border-t border-border-light"></div>
<!-- Ungroup -->
<button
class="px-4 py-2 font-normal hover:bg-red-500/10 cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm text-red-600 dark:text-red-400"
onclick={() => {
onDeleteGroup?.()
menuOpen = false
}}
>
<Ungroup size={14} class="shrink-0" />
<p class="truncate grow min-w-0 whitespace-nowrap text-left">Ungroup</p>
</button>
{/if}
</div>
{/snippet}
</DropdownV2>
</div>
@@ -0,0 +1,116 @@
<script lang="ts">
import { NOTE_COLORS, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import { ChevronRight } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
interface Props {
summary?: string
color?: string
collapsed: boolean
editMode: boolean
onToggleCollapse: () => void
onSummaryUpdate?: (text: string) => void
}
let { summary, color, collapsed, editMode, onToggleCollapse, onSummaryUpdate }: Props = $props()
let colorConfig = $derived(
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
)
const PLACEHOLDER = 'Group'
// Inline summary editing
let editingSummary = $state(false)
let summaryInput = $state('')
let textInputComponent: TextInput | undefined = $state(undefined)
function startEditingSummary() {
if (!editMode) return
editingSummary = true
summaryInput = summary ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
function saveSummary() {
editingSummary = false
const trimmed = summaryInput.trim()
if (trimmed !== (summary ?? '')) {
onSummaryUpdate?.(trimmed)
}
}
function handleSummaryKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveSummary()
} else if (event.key === 'Escape') {
editingSummary = false
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center h-[22px] w-full px-2 relative cursor-pointer {colorConfig.background} {colorConfig.text} {collapsed
? 'rounded-t-md'
: 'rounded-md'}"
onclick={stopPropagation(preventDefault(onToggleCollapse))}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title={collapsed ? 'Expand group' : 'Collapse group'}
>
<div
class="flex items-center justify-center shrink-0 opacity-60 transition-transform duration-100"
class:rotate-90={!collapsed}
>
<ChevronRight size={12} />
</div>
<div class="absolute inset-x-0 flex items-center justify-center h-full pointer-events-none px-7">
{#if editingSummary}
<div
class="input-sizer inline-grid items-center pointer-events-auto text-2xs font-medium max-w-full"
data-value={summaryInput || PLACEHOLDER}
>
<TextInput
bind:this={textInputComponent}
bind:value={summaryInput}
size="xs"
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 text-center !min-h-0 !h-auto nodrag nowheel"
inputProps={{
placeholder: PLACEHOLDER,
onblur: saveSummary,
onkeydown: handleSummaryKeydown,
spellcheck: false,
size: 1,
style: 'padding: 2px !important; grid-area: 1 / 1'
}}
/>
</div>
{:else}
<span
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
: ''}"
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{summary || PLACEHOLDER}</span
>
{/if}
</div>
</div>
<style>
.input-sizer::after {
content: attr(data-value) ' ';
visibility: hidden;
white-space: pre;
grid-area: 1 / 1;
font: inherit;
padding: 2px;
text-align: center;
}
</style>
@@ -0,0 +1,76 @@
<script lang="ts">
import GroupHeader from './GroupHeader.svelte'
import GroupNoteArea from './GroupNoteArea.svelte'
import GroupActionBar from './GroupActionBar.svelte'
import { getGroupEditorContext } from './groupEditor.svelte'
import { getGraphContext } from './graphContext'
interface Props {
groupId: string
summary?: string
note?: string | null
color?: string
collapsed: boolean
autocollapse: boolean
editMode: boolean
showNotes: boolean
}
let { groupId, summary, note, color, collapsed, autocollapse, editMode, showNotes }: Props =
$props()
const groupEditorContext = getGroupEditorContext()
const graphContext = getGraphContext()
const moveManager = graphContext?.moveManager
let moveModuleId = $derived(collapsed ? `collapsed-group:${groupId}` : `group:${groupId}`)
let hovered = $state(false)
let menuOpen = $state(false)
let actionBarHovered = $state(false)
let visible = $derived(hovered || menuOpen || actionBarHovered)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="nodrag relative"
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
>
<GroupHeader
{summary}
{color}
{collapsed}
{editMode}
onToggleCollapse={() => graphContext?.groupDisplayState?.toggleRuntimeCollapse(groupId)}
onSummaryUpdate={(text) => groupEditorContext?.groupEditor.updateSummary(groupId, text)}
/>
{#if showNotes && note != null}
<GroupNoteArea
note={note ?? ''}
{color}
{collapsed}
{editMode}
onHeightChange={(h) => graphContext?.groupDisplayState?.setNoteHeight(groupId, h)}
onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(groupId, text)}
/>
{/if}
{#if editMode}
<GroupActionBar
{note}
{color}
{autocollapse}
{visible}
{menuOpen}
{moveManager}
{moveModuleId}
onMenuOpenChange={(open) => (menuOpen = open)}
onAddNote={() => groupEditorContext?.groupEditor.addNote(groupId)}
onRemoveNote={() => groupEditorContext?.groupEditor.removeNote(groupId)}
onUpdateColor={(c) => groupEditorContext?.groupEditor.updateColor(groupId, c)}
onUpdateAutocollapse={(v) => groupEditorContext?.groupEditor.updateAutocollapse(groupId, v)}
onDeleteGroup={() => groupEditorContext?.groupEditor.deleteGroup(groupId)}
/>
{/if}
</div>
@@ -0,0 +1,182 @@
<script lang="ts">
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { getGraphContext } from './graphContext'
import { getNodeColorClasses } from '$lib/components/graph'
import type { FlowNodeState } from '$lib/components/graph/util'
import type { GraphModuleState } from './model'
import type { FlowModule } from '$lib/gen'
import type { GraphEventHandlers } from './graphBuilder.svelte'
interface Props {
modules: FlowModule[]
flowModuleStates?: Record<string, GraphModuleState> | undefined
eventHandlers?: GraphEventHandlers
}
let { modules, flowModuleStates, eventHandlers }: Props = $props()
const { selectionManager } = getGraphContext()
// Badge width model: icon(16) + pl(2) + gap(2) + pr(6) = 26px fixed + ~5.5px per char
const BADGE_FIXED = 26
const CHAR_WIDTH = 5.5
const BADGE_MAX = 128 // 8rem
const GAP = 4 // gap-1
const ROW_WIDTH = 255 // 275px container - 2*px-2 padding
const OVERFLOW_BTN_WIDTH = 32
function estimateBadgeWidth(id: string): number {
return BADGE_FIXED + id.length * CHAR_WIDTH
}
function totalWidth(mods: FlowModule[], capped: boolean): number {
return mods.reduce(
(sum, mod, i) =>
sum +
Math.min(estimateBadgeWidth(mod.id), capped ? BADGE_MAX : Infinity) +
(i > 0 ? GAP : 0),
0
)
}
let { displayModules, overflowModules, capIds } = $derived.by(() => {
// Step 1: try all badges with full ids
if (totalWidth(modules, false) <= ROW_WIDTH) {
return { displayModules: modules, overflowModules: [], capIds: false }
}
// Step 2: try all badges with ids capped at 8rem
if (totalWidth(modules, true) <= ROW_WIDTH) {
return { displayModules: modules, overflowModules: [], capIds: true }
}
// Step 3: remove last modules until capped badges fit
const available = ROW_WIDTH - OVERFLOW_BTN_WIDTH - GAP
let count = modules.length
while (count > 1 && totalWidth(modules.slice(0, count), true) > available) {
count--
}
return {
displayModules: modules.slice(0, count),
overflowModules: modules.slice(count),
capIds: true
}
})
const STATE_PRIORITY: Record<string, number> = {
WaitingForEvents: 4,
InProgress: 3,
WaitingForExecutor: 3,
Failure: 2,
Success: 1
}
let overflowAggregateState = $derived.by<FlowNodeState | undefined>(() => {
if (!flowModuleStates) return undefined
let best: FlowNodeState | undefined = undefined
let bestPriority = 0
for (const mod of overflowModules) {
const state = flowModuleStates[mod.id]?.type
if (state) {
const p = STATE_PRIORITY[state] ?? 0
if (p > bestPriority) {
bestPriority = p
best = state
}
}
}
return best
})
function moduleLabel(mod: FlowModule): string {
if (mod.summary) return mod.summary
const type = mod.value?.type
if (type === 'forloopflow') return 'For loop'
if (type === 'whileloopflow') return 'While loop'
if (type === 'branchone') return 'Run one branch'
if (type === 'branchall') return 'Run all branches'
if (type === 'flow') return 'Flow'
if (type === 'identity') return 'Identity'
if (type === 'aiagent') return 'AI Agent'
return mod.id
}
function selectModule(mod: FlowModule) {
selectionManager.selectId(mod.id)
eventHandlers?.select(mod.id)
}
</script>
<div class="flex items-center gap-1">
{#each displayModules as mod (mod.id)}
{@const selected = selectionManager.isNodeSelected(mod.id)}
{@const nodeState = flowModuleStates?.[mod.id]?.type}
{@const colorClasses = getNodeColorClasses(nodeState, selected)}
<Tooltip placement="bottom">
{#snippet children()}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="h-5 rounded-md overflow-hidden flex items-center gap-0.5 shrink-0 shadow-sm pl-0.5 pr-1.5 cursor-pointer hover:opacity-80 {colorClasses.bg} {colorClasses.outline}"
style={capIds ? 'max-width: 8rem;' : ''}
onclick={() => selectModule(mod)}
>
<div class="w-4 h-4 flex items-center justify-center shrink-0">
<FlowModuleIcon module={mod} size={12} />
</div>
<span class="text-3xs font-medium truncate text-right flex-1 {colorClasses.text}"
>{mod.id}</span
>
</div>
{/snippet}
{#snippet text()}
<span class="font-medium">{mod.id}</span>: {moduleLabel(mod)}
{/snippet}
</Tooltip>
{/each}
{#if overflowModules.length > 0}
{@const overflowColorClasses = getNodeColorClasses(overflowAggregateState, false)}
<DropdownV2 placement="bottom" customMenu usePointerDownOutside>
{#snippet buttonReplacement()}
<div
class="h-5 rounded-md flex items-center justify-center shrink-0 shadow-sm px-1.5 cursor-pointer hover:opacity-80 {overflowColorClasses.bg} {overflowColorClasses.outline}"
>
<span class="text-3xs font-medium {overflowColorClasses.text}"
>+{overflowModules.length}</span
>
</div>
{/snippet}
{#snippet menu()}
<div
class="bg-surface-tertiary dark:border rounded-lg shadow-lg py-1 w-56 overflow-y-auto"
style="max-height: 50vh;"
>
{#each overflowModules as mod (mod.id)}
{@const nodeState = flowModuleStates?.[mod.id]?.type}
{@const colorClasses = getNodeColorClasses(nodeState, false)}
{@const selected = selectionManager.isNodeSelected(mod.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-surface-hover text-2xs {selected
? 'bg-surface-accent-selected'
: ''}"
onclick={() => selectModule(mod)}
>
<div
class="w-4 h-4 rounded flex items-center justify-center shrink-0 {colorClasses.bg}"
>
<FlowModuleIcon module={mod} size={12} />
</div>
<span class="truncate flex-1">{moduleLabel(mod)}</span>
<span class="text-tertiary shrink-0">{mod.id}</span>
</div>
{/each}
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
@@ -0,0 +1,165 @@
<script lang="ts">
import { Group } from 'lucide-svelte'
import { getNodeColorClasses } from '$lib/components/graph'
import { NOTE_COLORS, NoteColor } from './noteColors'
import { NODE } from './util'
import { twMerge } from 'tailwind-merge'
import { preventDefault, stopPropagation } from 'svelte/legacy'
import GroupNoteArea from './GroupNoteArea.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import GroupModuleIcons from './GroupModuleIcons.svelte'
import type { FlowModule } from '$lib/gen'
interface Props {
summary?: string
selected?: boolean
stepCount?: number
color?: string
note?: string
showNote?: boolean
editMode?: boolean
modules?: FlowModule[]
onExpand?: () => void
onSummaryUpdate?: (text: string) => void
onNoteUpdate?: (text: string) => void
onHeightChange?: (height: number) => void
}
let {
summary,
selected = false,
stepCount,
color,
note,
showNote = false,
editMode = false,
modules,
onExpand,
onSummaryUpdate,
onNoteUpdate,
onHeightChange
}: Props = $props()
let noteColorConfig = $derived(
color ? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE]) : undefined
)
let defaultColorClasses = $derived(getNodeColorClasses(undefined, selected))
// Inline summary editing
let editingSummary = $state(false)
let summaryInput = $state('')
let textInputComponent: TextInput | undefined = $state(undefined)
function startEditingSummary() {
if (!editMode) return
editingSummary = true
summaryInput = summary ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
function saveSummary() {
editingSummary = false
const trimmed = summaryInput.trim()
if (trimmed !== (summary ?? '')) {
onSummaryUpdate?.(trimmed)
}
}
function handleSummaryKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveSummary()
} else if (event.key === 'Escape') {
editingSummary = false
}
}
// Reset height to 0 when note is hidden
$effect(() => {
if (!showNote) {
onHeightChange?.(0)
}
})
</script>
<div
class={twMerge(
'w-full module cursor-pointer max-w-full',
'shadow-sm rounded-md overflow-clip',
'bg-surface-tertiary'
)}
style="width: {NODE.width}px;"
>
<div
class={twMerge(
'absolute z-0 outline-offset-0 inset-0',
'rounded-md',
noteColorConfig ? noteColorConfig.outline : defaultColorClasses.outline
)}
></div>
<div class="flex items-center w-full gap-1.5 px-2 h-[34px] relative z-1">
{#if modules && modules.length > 0}
<GroupModuleIcons {modules} />
{:else}
<Group size={14} />
{/if}
<div
class="absolute inset-x-0 flex items-center justify-center h-[34px] pointer-events-none px-8"
>
{#if editingSummary}
<TextInput
bind:this={textInputComponent}
bind:value={summaryInput}
size="xs"
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 w-full text-center !min-h-0 !h-auto nodrag nowheel pointer-events-auto"
inputProps={{
placeholder: 'Group',
onblur: saveSummary,
onkeydown: handleSummaryKeydown,
spellcheck: false
}}
/>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
: ''}"
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{summary || 'Group'}</span
>
{/if}
</div>
<div class="flex-1"></div>
{#if stepCount != null}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="text-3xs opacity-60 shrink-0 whitespace-nowrap {noteColorConfig
? noteColorConfig.text
: ''} {onExpand
? 'cursor-pointer hover:opacity-100 hover:text-blue-500 dark:hover:text-blue-400'
: ''}"
onclick={onExpand ? stopPropagation(preventDefault(onExpand)) : undefined}
>{stepCount} node{stepCount !== 1 ? 's' : ''}</span
>
{/if}
</div>
{#if showNote}
<div class="relative z-1">
<GroupNoteArea
note={note ?? ''}
{color}
{editMode}
onHeightChange={(h) => onHeightChange?.(h)}
onNoteUpdate={(text) => onNoteUpdate?.(text)}
/>
</div>
{/if}
</div>
@@ -0,0 +1,151 @@
<script lang="ts">
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { Check, X } from 'lucide-svelte'
import { NOTE_COLORS, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
interface Props {
note: string
color?: string
collapsed?: boolean
editMode: boolean
onHeightChange: (height: number) => void
onNoteUpdate: (text: string) => void
}
let { note, color, collapsed = false, editMode, onHeightChange, onNoteUpdate }: Props = $props()
let editing = $state(false)
let editHeight = $state(0)
let textContent = $state('')
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let containerElement: HTMLDivElement | undefined = $state(undefined)
function autoResize(el: HTMLTextAreaElement) {
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
let noteColorConfig = $derived(
color
? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE])
: NOTE_COLORS[NoteColor.BLUE]
)
// Measure height and report to parent (skip while editing to avoid full graph rebuilds)
$effect(() => {
if (containerElement && !editing) {
const height = containerElement.clientHeight
onHeightChange(height)
}
})
// Also observe resize for dynamic content
$effect(() => {
if (!containerElement) return
const observer = new ResizeObserver((entries) => {
if (editing) return
for (const entry of entries) {
onHeightChange(entry.contentRect.height)
}
})
observer.observe(containerElement)
return () => {
observer.disconnect()
onHeightChange(0)
}
})
function handleDoubleClick() {
if (!editMode) return
editHeight = containerElement?.clientHeight ?? 0
editing = true
textContent = note
requestAnimationFrame(() => {
if (textareaElement) {
autoResize(textareaElement)
textareaElement.focus()
}
})
}
function handleSave() {
editing = false
if (textContent !== note) {
onNoteUpdate(textContent)
}
}
function handleCancel() {
editing = false
textContent = note
}
function handleKeydown(event: KeyboardEvent) {
event.stopPropagation()
if (event.key === 'Escape') {
handleSave()
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerElement}
class="nodrag nopan {collapsed ? 'mx-px' : 'w-full rounded-b-md'}"
>
<div class={collapsed ? 'relative' : 'w-full rounded-b-md relative'}>
{#if editing}
<div class="absolute top-0 right-1 flex gap-0.5 z-10">
<button
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
onpointerdown={stopPropagation(preventDefault(handleSave))}
title="Save (Esc)"
>
<Check size={11} />
</button>
<button
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
onpointerdown={stopPropagation(preventDefault(handleCancel))}
title="Cancel"
>
<X size={11} />
</button>
</div>
<textarea
bind:this={textareaElement}
bind:value={textContent}
class="w-full shadow-none resize-none !text-2xs overflow-y-auto border-none bg-transparent p-1 nodrag nopan nowheel focus:outline-none select-text {noteColorConfig.text}"
style:max-height="max({editHeight}px, 4lh)"
oninput={() => textareaElement && autoResize(textareaElement)}
placeholder="Write a note (markdown supported)"
onblur={handleSave}
onkeydown={handleKeydown}
onpointerdown={stopPropagation(() => {})}
spellcheck="false"
></textarea>
{:else if note}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {editMode
? 'cursor-pointer'
: ''}"
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
>
<GfmMarkdown md={note} noPadding />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="text-2xs italic opacity-60 p-2 {noteColorConfig.text} {editMode
? 'cursor-pointer'
: ''}"
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
>
Double click to edit the note
</div>
{/if}
</div>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { GROUP_HEADER_HEIGHT, groupKey, type FlowGroup } from './groupEditor.svelte'
import { getGraphContext } from './graphContext'
import { NoteColor, NOTE_COLORS } from './noteColors'
import type { GroupHeadN } from './graphBuilder.svelte'
interface Props {
allNodes: (Node & { type: string })[]
groups: FlowGroup[]
groupDepths: Record<string, number>
}
let { allNodes, groups, groupDepths }: Props = $props()
const graphContext = getGraphContext()
// Pre-compute bounds for all groups reactively (tracks allNodes measured changes)
let groupBoundsMap = $derived.by(() => {
const map: Record<string, { x: number; y: number; width: number; height: number } | null> = {}
const nodeMap = new Map(allNodes.map((n) => [n.id, n]))
for (const group of groups) {
if (graphContext?.groupDisplayState?.isRuntimeCollapsed(groupKey(group))) {
continue
}
const headId = `group:${groupKey(group)}`
const endId = `group:${groupKey(group)}-end`
const headNode = nodeMap.get(headId)
const endNode = nodeMap.get(endId)
if (headNode && endNode) {
const d = headNode.data as GroupHeadN['data']
const headCenterX = headNode.position.x + (headNode.measured?.width ?? 275) / 2
const wrapperWidth = d.wrapperWidth ?? 275
const headHeight = headNode.measured?.height ?? GROUP_HEADER_HEIGHT
const topY = headNode.position.y + headHeight / 2
map[groupKey(group)] = {
x: headCenterX - wrapperWidth / 2,
y: topY,
width: wrapperWidth,
height: endNode.position.y - topY
}
} else {
map[groupKey(group)] = null
}
}
return map
})
function getOutlineColorClass(color?: string): string {
const config =
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
return config.outline
}
function getBgColorClass(color?: string): string {
return (
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE]?.backgroundLight ??
NOTE_COLORS[NoteColor.BLUE].backgroundLight
)
}
const moveManager = graphContext?.moveManager
function isGroupDragged(groupId: string): boolean {
if (!moveManager) return false
return (
moveManager.draggedNodeIds.has(`group:${groupId}`) ||
moveManager.draggedNodeIds.has(`collapsed-group:${groupId}`)
)
}
</script>
{#each groups as group (groupKey(group))}
{@const bounds = groupBoundsMap[groupKey(group)]}
{#if bounds}
<ViewportPortal target="back">
<div
class="absolute rounded-lg outline outline-1 -outline-offset-1 pointer-events-none {getOutlineColorClass(
group.color
)} {getBgColorClass(group.color)}"
class:opacity-30={isGroupDragged(groupKey(group))}
style:transform="translate({bounds.x}px, {bounds.y}px)"
style:width="{bounds.width}px"
style:height="{bounds.height}px"
style:z-index={-10 + (groupDepths[groupKey(group)] ?? 0)}
></div>
</ViewportPortal>
{/if}
{/each}
@@ -1,6 +1,12 @@
<script lang="ts">
import { writable } from 'svelte/store'
import { SvelteFlow, SvelteFlowProvider, type Node, type Edge, type Viewport } from '@xyflow/svelte'
import {
SvelteFlow,
SvelteFlowProvider,
type Node,
type Edge,
type Viewport
} from '@xyflow/svelte'
import { setGraphContext } from './graphContext'
import { SelectionManager } from './selectionUtils.svelte'
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
@@ -20,19 +26,37 @@
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
import AiToolNode from './renderers/nodes/AIToolNode.svelte'
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
import BaseEdge from './renderers/edges/BaseEdge.svelte'
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
let {
nodes,
edges,
nodes: nodesProp,
edges: edgesProp,
width,
height,
initialViewport
}: { nodes: Node[]; edges: Edge[]; width: number; height: number; initialViewport?: Viewport } =
$props()
}: {
nodes: Node[]
edges: Edge[]
width: number
height: number
initialViewport?: Viewport
} = $props()
// Use $state.raw to avoid deep reactive proxies that trigger xyflow's performance warning
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
$effect(() => {
nodes = [...nodesProp]
})
$effect(() => {
edges = [...edgesProp]
})
setGraphContext({
selectionManager: new SelectionManager(),
@@ -58,7 +82,10 @@
asset: AssetNode,
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode
newAiTool: NewAiToolNode,
collapsedGroup: CollapsedGroupNode,
groupHead: GroupHeadNode,
groupEnd: GroupEndNode
} as any
const edgeTypes = {
@@ -1,47 +0,0 @@
<script lang="ts">
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
import { StickyNote } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { getGraphContext } from './graphContext'
import { tick } from 'svelte'
interface Props {
children: Snippet
selectedNodeIds: string[]
}
let { children, selectedNodeIds }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
// Get Graph context for clearFlowSelection function
const graphContext = getGraphContext()
const menuItems: ContextMenuItem[] = $derived([
{
id: 'create-group-note',
label: `Create group note (${selectedNodeIds.length} nodes)`,
icon: StickyNote,
disabled: selectedNodeIds.length === 0 || !noteEditorContext?.noteEditor,
onClick: () => {
if (selectedNodeIds.length > 0 && noteEditorContext?.noteEditor && graphContext) {
// Create the group note first
noteEditorContext.noteEditor.createGroupNote(selectedNodeIds)
// Wait for next tick to ensure DOM updates
tick().then(() => {
graphContext?.clearFlowSelection?.()
graphContext?.selectionManager.selectId(selectedNodeIds[0])
})
}
}
}
])
</script>
{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1}
<ContextMenu items={menuItems}>
{@render children()}
</ContextMenu>
{/if}
@@ -10,7 +10,11 @@
isOpen?: boolean
}
let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props()
let {
selectedColor,
onColorChange,
isOpen = $bindable(false)
}: Props = $props()
</script>
<Popover
@@ -1,10 +1,10 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { calculateNodesBoundsWithOffset } from './util'
import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte'
import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte'
import { Button } from '../common'
import DropdownV2 from '../DropdownV2.svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { getGroupEditorContext } from './groupEditor.svelte'
import { getGraphContext } from './graphContext'
import MoveHandleButton from './MoveHandleButton.svelte'
import { tick } from 'svelte'
@@ -36,18 +36,19 @@
let resolvedCount = $derived(resolvedModuleIds.length)
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
// Get GroupEditor context for group creation
const groupEditorContext = getGroupEditorContext()
// Get Graph context for clearFlowSelection function and moveManager
const graphContext = getGraphContext()
const moveManager = graphContext?.moveManager
function handleAddGroupNote() {
if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) {
// Create the group note first
noteEditorContext.noteEditor.createGroupNote(selectedNodes)
let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false)
function handleAddGroup() {
if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) {
const flowNodes = graphContext.getFlowNodes?.() ?? []
groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes)
// Wait for next tick to ensure DOM updates
tick().then(() => {
graphContext?.clearFlowSelection?.()
graphContext?.selectionManager.clearSelection()
@@ -74,13 +75,13 @@
shortcut: isMac() ? '⌫' : 'Del',
action: () => onDeleteSelected?.()
},
...(noteEditorContext?.noteEditor
...(groupEditorContext?.groupEditor
? [
{
displayName: 'Add note',
icon: StickyNote,
separatorTop: true,
action: handleAddGroupNote
displayName: 'Create group',
icon: Group,
action: handleAddGroup,
disabled: !canCreateGroup
}
]
: [])
@@ -1,5 +1,6 @@
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
import { NODE } from './util'
import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte'
type LayoutNode = {
id: string
@@ -14,7 +15,7 @@ type LayoutConstants = {
}
type CompoundGroup = {
type: 'branch' | 'loop'
type: 'branch' | 'loop' | 'group'
headId: string
endId: string
branches: {
@@ -27,9 +28,12 @@ type LayoutResult = {
positions: Map<string, { x: number; y: number }>
bbox: { width: number; height: number }
contentMinX: number
groupDimensions?: Map<string, { width: number; height: number }>
}
const LOOP_INDENT = 25
export const GROUP_PADDING = 16
export const GROUP_TOP_PADDING = 32
/**
* Detect compound groups from a flat list of node IDs.
@@ -83,6 +87,18 @@ function detectGroups(
endId: id,
branches: [{ labelId: `${baseId}-start`, innerIds }]
})
} else if (baseId.startsWith('group:')) {
// Group pattern: group:{groupId} head + group:{groupId}-end
// Body is everything reachable from head to end
const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap)
if (innerIds.length > 0) {
groups.push({
type: 'group',
headId: baseId,
endId: id,
branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }]
})
}
}
}
@@ -230,6 +246,29 @@ function runSugiyama(
* 5. Run sugiyama on the simplified graph
* 6. Expand wrapper positions back to absolute positions
*/
/**
* Build nodeSizes map for sugiyama from nodeExtraSpace.
* Each node's effective height = top + NODE.height + bottom.
*/
function buildNodeSizes(
nodeIds: string[],
constants: LayoutConstants,
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): Map<string, { width: number; height: number }> | undefined {
if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined
const sizes = new Map<string, { width: number; height: number }>()
for (const id of nodeIds) {
const extra = nodeExtraSpace.get(id)
if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) {
sizes.set(id, {
width: constants.nodeWidth + extra.left + extra.right,
height: constants.nodeHeight + extra.top + extra.bottom
})
}
}
return sizes.size > 0 ? sizes : undefined
}
const MAX_RECURSION_DEPTH = 50
function layoutLevel(
@@ -237,7 +276,8 @@ function layoutLevel(
allNodes: Map<string, LayoutNode>,
constants: LayoutConstants,
childrenMap: Map<string, string[]>,
depth: number = 0
depth: number = 0,
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): LayoutResult {
const positions = new Map<string, { x: number; y: number }>()
const nodeIdSet = new Set(nodeIds)
@@ -256,8 +296,15 @@ function layoutLevel(
const n = allNodes.get(id)!
return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) }
})
const result = runSugiyama(flatNodes, constants)
const extraSizes = buildNodeSizes(
flatNodes.map((n) => n.id),
constants,
nodeExtraSpace
)
const result = runSugiyama(flatNodes, constants, extraSizes)
for (const [id, pos] of result.positions) {
const extra = nodeExtraSpace?.get(id)
if (extra) pos.y += extra.top
positions.set(id, pos)
}
return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 }
@@ -322,7 +369,14 @@ function layoutLevel(
const branchNodeIds = [branch.labelId, ...branch.innerIds]
// Find sub-groups within this branch
const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1)
const result = layoutLevel(
branchNodeIds,
allNodes,
constants,
childrenMap,
depth + 1,
nodeExtraSpace
)
branchLayouts.push({
labelId: branch.labelId,
@@ -349,6 +403,16 @@ function layoutLevel(
maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
// head row + branch content + end row
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
} else if (group.type === 'group') {
// Group: body is centered with padding on all sides
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
const bodyHeight = branchLayouts[0]?.bbox.height ?? 0
wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth)
maxBranchHeight = bodyHeight
const headExtra = nodeExtraSpace?.get(group.headId)
const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING
// head row + body + bottom padding
wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING
} else {
// Loop: body is indented
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
@@ -395,13 +459,30 @@ function layoutLevel(
}
// Step 5: Run sugiyama on flattened nodes
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
// Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes
const extraSizes = buildNodeSizes(
flatNodes.map((n) => n.id),
constants,
nodeExtraSpace
)
const mergedSizes = new Map<string, { width: number; height: number }>()
if (extraSizes) {
for (const [id, size] of extraSizes) mergedSizes.set(id, size)
}
for (const [id, size] of wrapperSizes) mergedSizes.set(id, size)
const sugResult = runSugiyama(
flatNodes,
constants,
mergedSizes.size > 0 ? mergedSizes : undefined
)
// Step 6: Resolve absolute positions
// First, set positions for regular (non-group) nodes
// Apply per-node y-offset from nodeExtraSpace so decorations above have room
for (const [nid, pos] of sugResult.positions) {
if (groupByHeadId.has(nid)) continue // Handle groups separately
positions.set(nid, { x: pos.x, y: pos.y })
const extra = nodeExtraSpace?.get(nid)
positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) })
}
// Now expand group wrappers into absolute positions
@@ -411,9 +492,15 @@ function layoutLevel(
const rowHeight = constants.nodeHeight + constants.gapV
const isBranch = gl.group.type === 'branch'
const isGroup = gl.group.type === 'group'
// Position the head node at the top-center of the wrapper
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
// Apply extra top padding so decorations above the head node have room
const headExtra = nodeExtraSpace?.get(headId)
positions.set(headId, {
x: wrapperPos.x,
y: wrapperPos.y + (headExtra?.top ?? 0)
})
if (isBranch) {
// Reuse cached branchWidths and totalWidth
@@ -441,6 +528,26 @@ function layoutLevel(
x: wrapperPos.x,
y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV
})
} else if (isGroup) {
// Group: body is centered within wrapper (no x offset)
const headExtra = nodeExtraSpace?.get(gl.group.headId)
const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING
const bl = gl.branchLayouts[0]
if (bl) {
for (const [innerNodeId, innerPos] of bl.result.positions) {
positions.set(innerNodeId, {
x: wrapperPos.x + innerPos.x,
y: wrapperPos.y + groupHeadRow + innerPos.y
})
}
}
// Position end node below body
const bodyHeight = bl?.bbox.height ?? 0
positions.set(gl.group.endId, {
x: wrapperPos.x,
y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING
})
} else {
// Loop: position start, body, and end
const bl = gl.branchLayouts[0]
@@ -463,16 +570,34 @@ function layoutLevel(
}
}
// Collect group dimensions from this level and child layouts
const groupDimensions = new Map<string, { width: number; height: number }>()
for (const [headId, gl] of groupLayouts) {
groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight })
// Propagate child groupDimensions from recursive branch layouts
for (const bl of gl.branchLayouts) {
if (bl.result.groupDimensions) {
for (const [childId, dims] of bl.result.groupDimensions) {
groupDimensions.set(childId, dims)
}
}
}
}
// Compute overall bbox (nodes + group wrapper extents)
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const pos of positions.values()) {
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
minY = Math.min(minY, pos.y)
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
for (const [nid, pos] of positions) {
// Group end nodes are zero-height markers — skip them
if (nid.startsWith('group:') && nid.endsWith('-end')) continue
const extra = nodeExtraSpace?.get(nid)
minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0))
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0))
// Account for top decoration space above the node
minY = Math.min(minY, pos.y - (extra?.top ?? 0))
maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0))
}
// Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes)
for (const [headId, gl] of groupLayouts) {
@@ -492,7 +617,12 @@ function layoutLevel(
width: Math.max(bboxWidth, constants.nodeWidth),
height: Math.max(bboxHeight, 0)
}
return { positions, bbox: finalBbox, contentMinX }
return {
positions,
bbox: finalBbox,
contentMinX,
groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined
}
}
/**
@@ -500,10 +630,16 @@ function layoutLevel(
*
* Takes the flat list of nodes and edges from graphBuilder and produces
* absolute positions that account for compound structure (branches, loops).
*
* nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout.
* After layout, each node's y is shifted down by its top padding so decorations
* (assets, AI tools, group headers) have room above. Left/right padding widens the
* column allocated to the node so neighbors are pushed further away.
*/
export function compoundLayout(
nodes: { id: string; parentIds?: string[] }[],
constants?: Partial<LayoutConstants>
constants?: Partial<LayoutConstants>,
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): LayoutResult {
const c: LayoutConstants = {
nodeWidth: constants?.nodeWidth ?? NODE.width,
@@ -528,7 +664,7 @@ export function compoundLayout(
}
const nodeIds = nodes.map((n) => n.id)
const result = layoutLevel(nodeIds, allNodes, c, childrenMap)
const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace)
// Shift positions so minX=0 (left-aligned).
// FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2
@@ -0,0 +1,245 @@
import { describe, it, expect, vi } from 'vitest'
// Mock modules that transitively import CSS/Monaco
vi.mock('monaco-editor', () => ({}))
vi.mock('@xyflow/svelte', () => ({}))
vi.mock('./renderers/nodes/AssetNode.svelte', () => ({
assetDisplaysAsOutputInFlowGraph: () => false
}))
vi.mock('../modulesTest.svelte', () => ({}))
import type { GraphGroup } from './groupEditor.svelte'
import type { FlowModule } from '$lib/gen'
import {
buildStructureTree,
flattenStructureIds,
deriveGroupsFromStructure,
collectLeafIds,
findInStructure
} from './flowStructure'
function makeModule(id: string): FlowModule {
return {
id,
value: { type: 'rawscript', content: '', language: 'python3' } as any
} as FlowModule
}
function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule {
return {
id,
value: {
type: 'branchall',
branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) }))
} as any
} as FlowModule
}
function makeForloop(id: string, innerIds: string[]): FlowModule {
return {
id,
value: {
type: 'forloopflow',
modules: innerIds.map((iid) => makeModule(iid)),
iterator: { type: 'javascript', expr: '' }
} as any
} as FlowModule
}
function makeGroup(
id: string,
start_id: string,
end_id: string,
moduleIds: string[] = []
): GraphGroup {
return { id, start_id, end_id, moduleIds }
}
describe('buildStructureTree', () => {
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
it('builds structure tree for a valid group', () => {
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
const result = buildStructureTree(modules, groups)
// Should have a group node + the remaining leaf 'c'
expect(result).toHaveLength(2)
expect(result[0].kind).toBe('group')
expect(result[0].id).toBe('g1')
expect(result[0].branches[0].children).toHaveLength(2)
expect(result[1].kind).toBe('leaf')
expect(result[1].id).toBe('c')
})
it('throws on duplicate group IDs', () => {
const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i)
})
it('throws on inverted range (start_id after end_id)', () => {
const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i)
})
it('throws on partially overlapping groups', () => {
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i)
})
it('throws when group start_id is a virtual node (Input)', () => {
const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
})
it('throws when group end_id is a virtual node (Result)', () => {
const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
})
it('throws when group references Trigger', () => {
const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])]
expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i)
})
it('allows fully nested groups', () => {
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
const groups = [
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
makeGroup('inner', 'b', 'c', ['b', 'c'])
]
const result = buildStructureTree(mods, groups)
expect(result).toHaveLength(1) // outer group contains everything
expect(result[0].kind).toBe('group')
// Inner group should be nested
const outerChildren = result[0].branches[0].children
expect(outerChildren).toHaveLength(3) // a, inner-group, d
expect(outerChildren[1].kind).toBe('group')
expect(outerChildren[1].id).toBe('inner')
})
it('handles empty modules', () => {
const result = buildStructureTree([], [])
expect(result).toHaveLength(0)
})
it('handles container modules (forloop)', () => {
const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')]
const result = buildStructureTree(mods, [])
expect(result).toHaveLength(2)
expect(result[0].kind).toBe('forloopflow')
expect(result[0].branches).toHaveLength(1)
expect(result[0].branches[0].children).toHaveLength(2)
expect(result[0].branches[0].children[0].id).toBe('x')
})
it('handles groups inside containers', () => {
const mods = [makeForloop('loop', ['x', 'y', 'z'])]
const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])]
const result = buildStructureTree(mods, groups)
expect(result).toHaveLength(1)
expect(result[0].kind).toBe('forloopflow')
const innerChildren = result[0].branches[0].children
expect(innerChildren).toHaveLength(2) // group + z
expect(innerChildren[0].kind).toBe('group')
expect(innerChildren[0].id).toBe('g1')
})
it('throws when group spans parallel branches (branchall)', () => {
const mods = [
makeModule('a'),
makeBranchAll('ba', [
['x', 'y'],
['p', 'q']
]),
makeModule('c')
]
const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])]
expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/)
})
})
describe('flattenStructureIds', () => {
it('flattens a simple tree', () => {
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
const tree = buildStructureTree(modules, groups)
const ids = flattenStructureIds(tree)
expect(ids).toEqual(['a', 'b', 'c'])
})
it('flattens nested groups', () => {
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
const groups = [
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
makeGroup('inner', 'b', 'c', ['b', 'c'])
]
const tree = buildStructureTree(mods, groups)
const ids = flattenStructureIds(tree)
expect(ids).toEqual(['a', 'b', 'c', 'd'])
})
})
describe('deriveGroupsFromStructure', () => {
it('derives group definitions with correct start/end', () => {
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
const tree = buildStructureTree(modules, groups)
const derived = deriveGroupsFromStructure(tree)
expect(derived).toHaveLength(1)
expect(derived[0].start_id).toBe('a')
expect(derived[0].end_id).toBe('b')
})
it('derives nested groups', () => {
const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')]
const groups = [
makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']),
makeGroup('inner', 'b', 'c', ['b', 'c'])
]
const tree = buildStructureTree(mods, groups)
const derived = deriveGroupsFromStructure(tree)
expect(derived).toHaveLength(2)
expect(derived[0].start_id).toBe('a')
expect(derived[0].end_id).toBe('d')
expect(derived[1].start_id).toBe('b')
expect(derived[1].end_id).toBe('c')
})
})
describe('findInStructure', () => {
it('finds a leaf node', () => {
const modules = [makeModule('a'), makeModule('b')]
const tree = buildStructureTree(modules, [])
const found = findInStructure(tree, 'b')
expect(found).toBeDefined()
expect(found!.index).toBe(1)
})
it('finds a node inside a group', () => {
const modules = [makeModule('a'), makeModule('b'), makeModule('c')]
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
const tree = buildStructureTree(modules, groups)
const found = findInStructure(tree, 'b')
expect(found).toBeDefined()
expect(found!.index).toBe(1)
// parentChildren should be the group's branch children
expect(found!.parentChildren).toHaveLength(2)
})
it('finds a group node by group id', () => {
const modules = [makeModule('a'), makeModule('b')]
const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])]
const tree = buildStructureTree(modules, groups)
const found = findInStructure(tree, 'g1')
expect(found).toBeDefined()
expect(found!.index).toBe(0)
})
})
describe('collectLeafIds', () => {
it('collects all leaf module IDs including inside containers', () => {
const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')]
const tree = buildStructureTree(mods, [])
const ids = collectLeafIds(tree)
expect(ids).toEqual(['loop', 'x', 'y', 'c'])
})
})
@@ -0,0 +1,498 @@
import type { FlowModule } from '$lib/gen'
import type { FlowGroup, GraphGroup } from './groupEditor.svelte'
import { getContainerInnerArrays } from './groupEditor.svelte'
import { VIRTUAL_NODE_IDS } from './groupDetectionUtils'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall'
export type StructureBranch = {
label?: string
children: FlowStructureNode[]
}
export type FlowStructureNode = {
/** FlowModule.id for modules, groupKey(g) for groups */
id: string
kind: 'leaf' | 'group' | ContainerKind
/** Only present when kind === 'group' */
group?: FlowGroup
/** Only present when kind === 'group' — flat module IDs for step count */
moduleIds?: string[]
/** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */
branches: StructureBranch[]
}
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
// Building the structure tree
// ---------------------------------------------------------------------------
export function buildStructureTree(
modules: FlowModule[],
groups: GraphGroup[]
): FlowStructureNode[] {
const { items, consumed } = buildStructureTreeRecurse(modules, groups)
const unconsumed = groups.filter((g) => !consumed.has(g.id))
if (unconsumed.length > 0) {
throw new Error(
`Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` +
`their start/end nodes do not belong to the same branch`
)
}
return items
}
export function moduleToStructureNode(mod: FlowModule): FlowStructureNode {
const innerArrays = getContainerInnerArrays(mod)
if (innerArrays.length === 0) {
return { id: mod.id, kind: 'leaf', branches: [] }
}
const kind = (mod.value as any).type as ContainerKind
const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({
label,
children: [] // filled later by recursion
}))
return { id: mod.id, kind, branches }
}
function buildStructureTreeRecurse(
modules: FlowModule[],
groups: GraphGroup[]
): { items: FlowStructureNode[]; consumed: Set<string> } {
if (modules.length === 0) {
return { items: [], consumed: new Set() }
}
const indexMap = new Map<string, number>()
for (let i = 0; i < modules.length; i++) {
indexMap.set(modules[i].id, i)
}
// Reject duplicate group IDs
const seenGroupIds = new Set<string>()
for (const g of groups) {
if (seenGroupIds.has(g.id)) {
throw new Error(`Duplicate group id: '${g.id}'`)
}
seenGroupIds.add(g.id)
}
// Reject groups referencing virtual nodes
for (const g of groups) {
if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) {
throw new Error(
`Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger`
)
}
}
// Partition: groups for this level vs rest
const levelGroups: GraphGroup[] = []
const otherGroups: GraphGroup[] = []
for (const g of groups) {
if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) {
const s = indexMap.get(g.start_id)!
const e = indexMap.get(g.end_id)!
if (s > e) {
throw new Error(
`Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})`
)
}
levelGroups.push(g)
} else {
otherGroups.push(g)
}
}
// Validate no partial overlaps
for (let i = 0; i < levelGroups.length; i++) {
for (let j = i + 1; j < levelGroups.length; j++) {
const a = levelGroups[i]
const b = levelGroups[j]
const aStart = indexMap.get(a.start_id)!
const aEnd = indexMap.get(a.end_id)!
const bStart = indexMap.get(b.start_id)!
const bEnd = indexMap.get(b.end_id)!
if (aEnd < bStart || bEnd < aStart) continue
if (aStart <= bStart && bEnd <= aEnd) continue
if (bStart <= aStart && aEnd <= bEnd) continue
throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`)
}
}
// Build grouped structure for this level
function build(
startIdx: number,
endIdx: number,
availableGroups: GraphGroup[]
): FlowStructureNode[] {
const result: FlowStructureNode[] = []
let i = startIdx
while (i <= endIdx) {
const candidates = availableGroups.filter((g) => {
const gStart = indexMap.get(g.start_id)!
const gEnd = indexMap.get(g.end_id)!
return gStart === i && gEnd <= endIdx
})
candidates.sort((a, b) => {
const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)!
const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)!
return spanB - spanA
})
const group = candidates[0]
if (group) {
const gEnd = indexMap.get(group.end_id)!
const remaining = availableGroups.filter((g) => g.id !== group.id)
const innerNodes = build(i, gEnd, remaining)
const moduleIds: string[] = []
for (let k = i; k <= gEnd; k++) {
moduleIds.push(modules[k].id)
}
result.push({
id: group.id,
kind: 'group',
group: {
summary: group.summary,
note: group.note,
color: group.color,
autocollapse: group.autocollapse,
start_id: group.start_id,
end_id: group.end_id
},
moduleIds,
branches: [{ children: innerNodes }]
})
i = gEnd + 1
} else {
result.push(moduleToStructureNode(modules[i]))
i++
}
}
return result
}
const result = build(0, modules.length - 1, levelGroups)
// Recurse into containers with remaining unconsumed groups
const consumed = new Set(levelGroups.map((g) => g.id))
let remaining = otherGroups
function recurseIntoContainers(items: FlowStructureNode[]): void {
for (const item of items) {
if (item.kind === 'group') {
recurseIntoContainers(item.branches[0].children)
continue
}
if (item.branches.length === 0) continue
// This is a container module — get inner FlowModule arrays and recurse
const modIdx = indexMap.get(item.id)
if (modIdx === undefined) continue
const mod = modules[modIdx]
const innerArrays = getContainerInnerArrays(mod)
for (let bi = 0; bi < innerArrays.length; bi++) {
const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining)
item.branches[bi] = {
label: item.branches[bi]?.label,
children: inner.items
}
for (const id of inner.consumed) consumed.add(id)
remaining = remaining.filter((g) => !inner.consumed.has(g.id))
}
}
}
recurseIntoContainers(result)
return { items: result, consumed }
}
// ---------------------------------------------------------------------------
// Traversal utilities
// ---------------------------------------------------------------------------
/** Generic DFS over the structure tree */
export function dfsStructure(
nodes: FlowStructureNode[],
fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void
): void {
for (const node of nodes) {
fn(node, nodes)
for (const branch of node.branches) {
dfsStructure(branch.children, fn)
}
}
}
/** Flatten to ordered module IDs (groups are transparent) */
export function flattenStructureIds(nodes: FlowStructureNode[]): string[] {
const ids: string[] = []
for (const node of nodes) {
if (node.kind === 'group') {
ids.push(...flattenStructureIds(node.branches[0].children))
} else {
ids.push(node.id)
}
}
return ids
}
/** Collect leaf module IDs recursively (including inside containers) */
export function collectLeafIds(nodes: FlowStructureNode[]): string[] {
const ids: string[] = []
for (const node of nodes) {
if (node.kind === 'group') {
ids.push(...collectLeafIds(node.branches[0].children))
} else {
ids.push(node.id)
for (const branch of node.branches) {
ids.push(...collectLeafIds(branch.children))
}
}
}
return ids
}
// ---------------------------------------------------------------------------
// Finding nodes in the tree
// ---------------------------------------------------------------------------
export type FindResult = { parentChildren: FlowStructureNode[]; index: number }
export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.id === id) return { parentChildren: nodes, index: i }
for (const branch of node.branches) {
const found = findInStructure(branch.children, id)
if (found) return found
}
}
return undefined
}
/**
* Match a structure node against a graph node ID.
* Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X.
*/
export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean {
if (node.id === nodeId) return true
if (node.kind === 'group') {
return (
nodeId === `group:${node.id}` ||
nodeId === `group:${node.id}-end` ||
nodeId === `collapsed-group:${node.id}`
)
}
return false
}
/**
* Find insert index using graph node IDs (handles group:X-end etc.).
* Returns the index OF the matched item (insert before it).
* For group-end nodes, returns index AFTER the group (insert after it).
*/
export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number {
// group-end: insert after the group
if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) {
const groupId = targetNodeId.slice('group:'.length, -'-end'.length)
const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId)
return idx >= 0 ? idx + 1 : items.length
}
// Everything else: insert at the matched item's position
for (let i = 0; i < items.length; i++) {
if (matchStructureNode(items[i], targetNodeId)) return i
}
return items.length
}
// ---------------------------------------------------------------------------
// Deriving groups from the structure tree
// ---------------------------------------------------------------------------
export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] {
const groups: FlowGroup[] = []
for (const node of nodes) {
if (node.kind === 'group' && node.group) {
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length === 0) {
console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`)
continue
}
groups.push({
...node.group,
start_id: flatIds[0],
end_id: flatIds[flatIds.length - 1]
})
// Recurse for nested groups
groups.push(...deriveGroupsFromStructure(node.branches[0].children))
} else {
for (const branch of node.branches) {
groups.push(...deriveGroupsFromStructure(branch.children))
}
}
}
return groups
}
// ---------------------------------------------------------------------------
// Syncing structure back to FlowModule[]
// ---------------------------------------------------------------------------
/**
* Reconstruct a FlowModule[] from the structure tree, looking up originals
* from moduleMap and patching container inner arrays to match the tree ordering.
*/
export function applyStructureToModules(
nodes: FlowStructureNode[],
moduleMap: Map<string, FlowModule>
): FlowModule[] {
const result: FlowModule[] = []
for (const node of nodes) {
if (node.kind === 'group') {
// Groups are transparent — splice their children into this level
result.push(...applyStructureToModules(node.branches[0].children, moduleMap))
} else {
const mod = moduleMap.get(node.id)
if (!mod) continue
// Patch container inner arrays
if (node.branches.length > 0) {
const innerArrays = getContainerInnerArrays(mod)
for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) {
innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap))
}
}
result.push(mod)
}
}
return result
}
// ---------------------------------------------------------------------------
// Empty groups cleanup
// ---------------------------------------------------------------------------
/**
* Walk the tree, remove group nodes that have no leaf modules, and return
* the removed groups. Mutates the input array in-place.
* Recurses depth-first so inner groups are cleaned before checking outer ones.
*/
export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const removed: FlowGroup[] = []
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (node.kind === 'group' && node.group) {
// Recurse first — inner groups may become empty too
removed.push(...removeEmptyGroups(node.branches[0].children))
if (flattenStructureIds(node.branches[0].children).length === 0) {
removed.push(node.group)
nodes.splice(i, 1)
}
} else {
for (const branch of node.branches) {
removed.push(...removeEmptyGroups(branch.children))
}
}
}
return removed
}
/** Walk the structure tree to compute nesting depth for each group (O(n)). */
export function computeGroupDepths(tree: FlowStructureNode[]): Record<string, number> {
const depths: Record<string, number> = {}
function walk(nodes: FlowStructureNode[], groupDepth: number): void {
for (const node of nodes) {
if (node.kind === 'group') {
depths[node.id] = groupDepth
for (const branch of node.branches) {
walk(branch.children, groupDepth + 1)
}
} else {
for (const branch of node.branches) {
walk(branch.children, groupDepth)
}
}
}
}
walk(tree, 0)
return depths
}
/**
* Find duplicate groups in the structure tree (same start_id:end_id after mutation).
* Returns the groups that should be removed (keeps the first, removes subsequent duplicates).
*/
export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const duplicates: FlowGroup[] = []
const seen = new Set<string>()
function walk(items: FlowStructureNode[]): void {
for (const node of items) {
if (node.kind === 'group' && node.group) {
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length > 0) {
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
if (seen.has(key)) {
duplicates.push(node.group)
} else {
seen.add(key)
}
}
walk(node.branches[0].children)
} else {
for (const branch of node.branches) {
walk(branch.children)
}
}
}
}
walk(nodes)
return duplicates
}
/** Remove duplicate groups from the structure tree (keeps first occurrence). */
export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] {
const removed: FlowGroup[] = []
const seen = new Set<string>()
function walk(items: FlowStructureNode[]): void {
for (let i = items.length - 1; i >= 0; i--) {
const node = items[i]
if (node.kind === 'group' && node.group) {
walk(node.branches[0].children)
const flatIds = flattenStructureIds(node.branches[0].children)
if (flatIds.length > 0) {
const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}`
if (seen.has(key)) {
// Replace group node with its children (ungroup)
removed.push(node.group)
items.splice(i, 1, ...node.branches[0].children)
} else {
seen.add(key)
}
}
} else {
for (const branch of node.branches) {
walk(branch.children)
}
}
}
}
walk(nodes)
return removed
}
@@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib'
import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte'
import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import {
type FlowStructureNode,
collectLeafIds,
findInsertIndexByNodeId,
buildStructureTree
} from './flowStructure'
import { groupKey, type FlowGroup } from './groupEditor.svelte'
import { computeGroupModuleIds } from './groupDetectionUtils'
export type InsertKind =
| 'script'
@@ -62,6 +70,7 @@ export type GraphEventHandlers = {
simplifyFlow: (b: boolean) => void
expandSubflow: (id: string, path: string) => void
minimizeSubflow: (id: string) => void
expandGroup: (groupId: string) => void
updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void
testUpTo: (id: string) => void
editInput: (moduleId: string, key: string) => void
@@ -111,6 +120,9 @@ export type FlowNode =
| AssetsOverflowedN
| AiToolN
| NewAiToolN
| CollapsedGroupN
| GroupHeadN
| GroupEndN
export type InputN = {
type: 'input2'
@@ -316,6 +328,48 @@ export type NewAiToolN = {
}
}
export type CollapsedGroupN = {
type: 'collapsedGroup'
data: {
groupId: string
summary: string | undefined
note: string | undefined
color: string | undefined
autocollapse: boolean | undefined
stepCount: number
modules: FlowModule[]
flowModuleStates: Record<string, GraphModuleState> | undefined
flowJob: Job | undefined
isOwner: boolean
suspendStatus: Record<string, { job: Job; nb: number }>
showNotes: boolean
editMode: boolean
eventHandlers: GraphEventHandlers
}
}
export type GroupHeadN = {
type: 'groupHead'
data: {
groupId: string
summary: string | undefined
note: string | undefined
color: string | undefined
autocollapse: boolean | undefined
editMode: boolean
showNotes: boolean
eventHandlers: GraphEventHandlers
wrapperWidth?: number
}
}
export type GroupEndN = {
type: 'groupEnd'
data: {
groupId: string
}
}
export function topologicalSort(
nodes: { id: string; parentIds?: string[] }[]
): { id: string; parentIds?: string[] }[] {
@@ -336,22 +390,8 @@ export function topologicalSort(
return result.reverse()
}
// input2: InputNode,
// module: ModuleNode,
// branchAllStart: BranchAllStart,
// branchAllEnd: BranchAllEndNode,
// forLoopEnd: ForLoopEndNode,
// forLoopStart: ForLoopStartNode,
// result: ResultNode,
// whileLoopStart: ForLoopStartNode,
// whileLoopEnd: ForLoopEndNode,
// branchOneStart: BranchOneStart,
// branchOneEnd: BranchOneEndNode,
// subflowBound: SubflowBound,
// noBranch: NoBranchNode,
// trigger: TriggersNode
export function graphBuilder(
structureTree: FlowStructureNode[],
modules: FlowModule[] | undefined,
extra: {
disableAi: boolean
@@ -383,11 +423,9 @@ export function graphBuilder(
selectedId: string | undefined,
simplifiableFlow: SimplifiableFlow | undefined,
flowPathForTriggerNode: string | undefined,
expandedSubflows: Record<string, FlowModule[]>
// triggerProps?: {
// path?: string
// flowIsSimplifiable?: boolean
// }
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>,
showNotes: boolean,
collapsedGroupIds: Set<string>
): {
nodes: { [key: string]: NodeLayout }
edges: Edge[]
@@ -403,7 +441,13 @@ export function graphBuilder(
const nodes: NodeLayout[] = []
const edges: Edge[] = []
function addNode(module: FlowModule) {
// Lookup map from module ID to the original reactive FlowModule objects.
const moduleMap = new Map<string, FlowModule>()
for (const m of getAllModules(modules, failureModule)) {
moduleMap.set(m.id, m)
}
function addNode(module: FlowModule, extraData?: Record<string, any>) {
const duplicated = nodes.find((n) => n.id === module.id)
if (duplicated) {
console.log('Duplicated node detected: ', module, duplicated)
@@ -424,7 +468,8 @@ export function graphBuilder(
isOwner: extra.isOwner,
flowJob: extra.flowJob,
assets: getFlowModuleAssets(module, extra.additionalAssetsMap),
moduleAction: extra.moduleActions?.[module.id]
moduleAction: extra.moduleActions?.[module.id],
...extraData
},
type: 'module',
selectable: true
@@ -483,14 +528,20 @@ export function graphBuilder(
customId?: string
type?: string
subModules?: FlowModule[]
currentItems?: FlowStructureNode[]
disableMoveIds?: string[]
}
) {
parents[targetId] = [...(parents[targetId] ?? []), sourceId]
const mods = options?.subModules ?? modules
let index = mods?.findIndex((m) => m.id === targetId) ?? -1
let index: number
if (options?.currentItems) {
index = findInsertIndexByNodeId(options.currentItems, targetId)
} else {
const mods = options?.subModules ?? modules
const found = mods?.findIndex((m) => m.id === targetId) ?? -1
index = found >= 0 ? found : (mods?.length ?? 0)
}
const visited = new Set<string>()
const recStack = new Set<string>()
@@ -514,8 +565,7 @@ export function graphBuilder(
simplifiedTriggerView: simplifiableFlow?.simplifiedFlow,
disableMoveIds: options?.disableMoveIds,
enableTrigger: sourceId === 'Input',
// If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array
index: index >= 0 ? index : (mods?.length ?? 0),
index,
...extra,
insertable: extra.insertable && !options?.disableInsert && prefix == undefined,
shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId)
@@ -591,7 +641,7 @@ export function graphBuilder(
}
function processModules(
modules: FlowModule[],
items: FlowStructureNode[],
branch: { rootId: string; branch: number } | undefined,
beforeNode: NodeLayout,
nextNode: NodeLayout | undefined,
@@ -600,31 +650,166 @@ export function graphBuilder(
disableMoveIds: string[] = [],
parentIndex?: string
) {
// For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs
// (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived)
if (prefix != undefined) {
modules.forEach((m) => {
if (!m['oid']) {
m['oid'] = m.id
items.forEach((item) => {
if (item.kind === 'group') return
const m = moduleMap.get(item.id)
if (m) {
const oid = m['oid'] ?? m.id
const newId = 'subflow:' + prefix + oid
const clone = { ...m, id: newId, oid } as FlowModule & { oid: string }
clone['oid'] = oid
moduleMap.set(newId, clone)
item.id = newId
}
m.id = 'subflow:' + prefix + m['oid']
})
}
let previousId: string | undefined = undefined
if (modules.length === 0) {
if (items.length === 0) {
if (nextNode) {
addEdge(beforeNode.id, nextNode.id, branch, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds
})
}
} else {
modules.forEach((module, index) => {
items.forEach((item, index) => {
// --- Group items ---
if (item.kind === 'group') {
const g = item.group!
const gId = item.id
if (collapsedGroupIds.has(gId)) {
// Collapsed group: single node
const nodeId = `collapsed-group:${gId}`
const leafIds = collectLeafIds(item.branches[0].children)
nodes.push({
id: nodeId,
data: {
groupId: gId,
summary: g.summary,
note: g.note,
color: g.color,
autocollapse: g.autocollapse,
stepCount: item.moduleIds?.length ?? 0,
modules: leafIds
.map((id) => moduleMap.get(id))
.filter((m): m is FlowModule => !!m),
flowModuleStates: extra.flowModuleStates,
flowJob: extra.flowJob,
isOwner: extra.isOwner,
suspendStatus: extra.suspendStatus,
showNotes,
editMode: prefix == undefined && extra.editMode,
eventHandlers
},
type: 'collapsedGroup',
selectable: false
})
// Wire: previous → collapsedGroup
if (index > 0 && previousId) {
addEdge(previousId, nodeId, branch, prefix, {
currentItems: items,
disableMoveIds
})
}
previousId = nodeId
} else {
// Expanded group: head → recurse → end
const headId = `group:${gId}`
const endId = `group:${gId}-end`
const localDisableMoveIds = [...disableMoveIds, headId]
const headNode: NodeLayout = {
id: headId,
data: {
groupId: gId,
summary: g.summary,
note: g.note,
color: g.color,
autocollapse: g.autocollapse,
editMode: prefix == undefined && extra.editMode,
showNotes,
eventHandlers
},
type: 'groupHead',
selectable: false
}
const endNode: NodeLayout = {
id: endId,
data: {
groupId: gId
},
type: 'groupEnd',
selectable: false
}
nodes.push(headNode)
nodes.push(endNode)
// Wire: previous → headNode
if (index > 0 && previousId) {
addEdge(previousId, headId, branch, prefix, {
currentItems: items,
disableMoveIds
})
}
// Recurse inner modules
processModules(
item.branches[0].children,
{ rootId: headId, branch: 0 },
headNode,
endNode,
simplifiedTriggerView,
prefix,
localDisableMoveIds,
parentIndex
)
previousId = endId
}
// Shared first/last edge wiring for groups
if (index === 0) {
addEdge(
beforeNode.id,
collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`,
undefined,
prefix,
{
currentItems: items,
disableMoveIds,
disableInsert: simplifiedTriggerView
}
)
}
if (index === items.length - 1 && previousId && nextNode) {
addEdge(previousId, nextNode.id, branch, prefix, {
currentItems: items,
disableMoveIds
})
}
return
}
// --- Regular FlowModule items ---
const module = moduleMap.get(item.id)
if (!module) return
const localDisableMoveIds = [...disableMoveIds, module.id]
// Add the edge between the previous node and the current one
// Inter-module edge: connect previous → current (expanded subflows handle their own)
if (index > 0 && previousId && expandedSubflows[module.id] == undefined) {
addEdge(previousId, module.id, branch, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds
})
}
@@ -700,7 +885,7 @@ export function graphBuilder(
)
processModules(
branch.modules,
item.branches[branchIndex]?.children ?? [],
{ rootId: module.id, branch: branchIndex },
startNode,
endNode,
@@ -722,7 +907,7 @@ export function graphBuilder(
id: `${module.id}-start`,
data: {
id: module.id,
module: module,
module: moduleMap.get(module.id) ?? module,
simplifiedTriggerView,
eventHandlers: eventHandlers,
editMode: extra.editMode,
@@ -759,7 +944,7 @@ export function graphBuilder(
const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex
processModules(
module.value.modules,
item.branches[0]?.children ?? [],
{ rootId: module.id, branch: 0 },
startNode,
endNode,
@@ -798,7 +983,7 @@ export function graphBuilder(
const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex
processModules(
module.value.modules,
item.branches[0]?.children ?? [],
{ rootId: module.id, branch: 0 },
startNode,
endNode,
@@ -825,21 +1010,6 @@ export function graphBuilder(
}
nodes.push(endNode)
// // Add default branch
// const defaultBranch: NodeLayout = {
// id: `${module.id}-default`,
// data: {
// offset: 0,
// label: 'Default',
// id: module.id,
// branchIndex: -1,
// eventHandlers: eventHandlers,
// branchOne: true,
// ...extra
// },
// type: 'noBranch'
// }
const defaultBranch: NodeLayout = {
id: `${module.id}-branch-default`,
data: {
@@ -863,7 +1033,7 @@ export function graphBuilder(
})
processModules(
module.value.default,
item.branches[0]?.children ?? [],
{ rootId: module.id, branch: 0 },
defaultBranch,
endNode,
@@ -899,7 +1069,7 @@ export function graphBuilder(
})
processModules(
branch.modules,
item.branches[branchIndex + 1]?.children ?? [],
{ rootId: module.id, branch: branchIndex + 1 },
startNode,
endNode,
@@ -912,9 +1082,9 @@ export function graphBuilder(
previousId = endNode.id
} else {
let expanded = expandedSubflows[module.id]
if (expanded) {
expanded = $state.snapshot(expanded)
const expandedData = expandedSubflows[module.id]
if (expandedData) {
const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[]
const startId = `${module.id}`
const idWithoutPrefix = module.id.startsWith('subflow:')
? module.id.substring(8)
@@ -936,12 +1106,12 @@ export function graphBuilder(
if (previousId) {
addEdge(previousId, startNode.id, branch, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds
})
} else {
addEdge(beforeNode.id, startNode.id, undefined, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds
})
}
@@ -962,8 +1132,20 @@ export function graphBuilder(
nodes.push(endNode)
// Register expanded subflow modules so prefix rewriting finds
// the inner modules (not the parent flow's modules with same IDs)
for (const em of getAllModules(expandedMods)) {
moduleMap.set(em.id, em)
}
const expandedGroups = (expandedData.groups ?? []).map((g) => ({
...g,
id: groupKey(g),
moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods))
}))
processModules(
expanded,
buildStructureTree(expandedMods, expandedGroups),
undefined,
startNode,
endNode,
@@ -981,15 +1163,15 @@ export function graphBuilder(
if (index === 0 && expandedSubflows[module.id] == undefined) {
addEdge(beforeNode.id, module.id, undefined, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds,
disableInsert: simplifiedTriggerView
})
}
if (index === modules.length - 1 && previousId && nextNode) {
if (index === items.length - 1 && previousId && nextNode) {
addEdge(previousId, nextNode.id, branch, prefix, {
subModules: modules,
currentItems: items,
disableMoveIds
})
}
@@ -997,10 +1179,12 @@ export function graphBuilder(
}
}
const topLevelItems = structureTree
if (simplifiableFlow?.simplifiedFlow === true && triggerNode) {
processModules(modules, undefined, triggerNode, undefined, true, undefined)
processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined)
} else {
processModules(modules, undefined, inputNode, resultNode, false, undefined)
processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined)
}
if (failureModule) {
@@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte'
import type { MoveManager } from './moveManager.svelte'
import type { Writable } from 'svelte/store'
import type { FlowDiffManager } from '../flows/flowDiffManager.svelte'
import type { GroupDisplayState } from './groupEditor.svelte'
export type GraphContext = {
selectionManager: SelectionManager
@@ -14,6 +15,9 @@ export type GraphContext = {
clearFlowSelection?: () => void
yOffset?: number
diffManager: FlowDiffManager
/** Current flow nodes for group validation (set by FlowGraphV2) */
getFlowNodes?: () => { id: string; parentIds?: string[] }[]
groupDisplayState?: GroupDisplayState
}
const graphContextKey = 'FlowGraphContext'
@@ -1,7 +1,127 @@
import { topologicalSort } from './graphBuilder.svelte'
/** Node IDs synthesized by graphBuilder that are not real FlowModules */
export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger'])
type FlowNode = { id: string; parentIds?: string[] }
/**
* Use a simple algorithm to complete a group and split it into connected components
* Compute the set of module IDs that belong to a group defined by start_id and end_id.
* Uses the flattened module list (from getAllModules) and slices between start and end.
* Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping.
*/
export function computeGroupModuleIds(
startId: string,
endId: string,
allModules: { id: string }[]
): string[] {
if (startId === endId) {
return allModules.some((m) => m.id === startId) ? [startId] : []
}
const startIdx = allModules.findIndex((m) => m.id === startId)
const endIdx = allModules.findIndex((m) => m.id === endId)
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
if (startIdx > endIdx) {
console.warn(
`computeGroupModuleIds: inverted range for group ${startId}${endId} (${startIdx} > ${endIdx})`
)
}
return []
}
return allModules.slice(startIdx, endIdx + 1).map((m) => m.id)
}
/**
* Check whether a set of selected node IDs can form a valid group.
* Normalizes marker IDs (branch/forloop) to parent module IDs,
* then uses topologicalSort to derive start and end boundaries.
*/
export function canFormValidGroup(
selectedIds: string[],
flowNodes: FlowNode[],
excludeIds?: Set<string>
): { valid: true; startId: string; endId: string } | { valid: false } {
if (selectedIds.length === 0) return { valid: false }
// Normalize marker IDs to parent module IDs.
// -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject.
const rawSet = new Set(selectedIds)
const normalizedIds: string[] = []
for (const id of selectedIds) {
const parentId = id.replace(/-(end|start|branch-.*)$/, '')
if (parentId === id) {
normalizedIds.push(id)
continue
}
if (id.endsWith('-start')) {
normalizedIds.push(parentId)
continue
}
// -end or -branch-*: parent must be covered (directly or via -start)
if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) {
return { valid: false }
}
}
if (normalizedIds.length === 0) return { valid: false }
const normalizedSet = new Set(normalizedIds)
// Topo sort full graph, filter to normalized selection.
// Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected.
const sorted = topologicalSort(flowNodes)
const selectedSorted = sorted.filter((n) => {
if (normalizedSet.has(n.id)) return true
const parentId = n.id.replace(/-(end|start|branch-.*)$/, '')
return parentId !== n.id && normalizedSet.has(parentId)
})
if (selectedSorted.length === 0) return { valid: false }
// Reject virtual or excluded nodes
if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) {
return { valid: false }
}
// Topo order is bottom-first: first = bottom (end), last = top (start).
// Use raw IDs for BFS traversal, normalize for the returned group boundaries.
const rawStartId = selectedSorted[selectedSorted.length - 1].id
const rawEndId = selectedSorted[0].id
const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '')
const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '')
// Verify all selected nodes lie between start and end in the DAG.
// BFS backward from rawEndId to rawStartId to collect reachable nodes.
// Normalize collected IDs so container markers map to their parent module.
const between = new Set<string>()
const queue = [rawEndId]
const visited = new Set<string>()
const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []]))
while (queue.length > 0) {
const cur = queue.shift()!
if (visited.has(cur)) continue
visited.add(cur)
const normalized = cur.replace(/-(end|start|branch-.*)$/, '')
between.add(cur)
between.add(normalized)
if (cur === rawStartId) continue
for (const p of parentMap.get(cur) ?? []) {
queue.push(p)
}
}
if (!normalizedIds.every((id) => between.has(id))) {
return { valid: false }
}
return { valid: true, startId, endId }
}
/**
* Legacy utility: complete a group and split it into connected components.
* Still used by NoteEditor for FlowNote group notes (contained_node_ids).
*/
export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] {
if (groupNodes.length <= 1) {
@@ -0,0 +1,325 @@
import type { FlowModule } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { ExtendedOpenFlow } from '../flows/types'
import { canFormValidGroup } from './groupDetectionUtils'
import type { NoteColor } from './noteColors'
import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors'
import { getContext, setContext } from 'svelte'
/**
* Type for a flow group (matches the generated type from OpenAPI).
* Members are computed dynamically from all nodes on paths between start_id and end_id.
*/
export type FlowGroup = {
summary?: string
note?: string
autocollapse?: boolean
start_id: string
end_id: string
color?: string
}
/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */
export function groupKey(g: { start_id: string; end_id: string }): string {
return `${g.start_id}:${g.end_id}`
}
/**
* Display state for flow groups inside the graph.
* Handles runtime collapse state and note height tracking.
* Similar to NoteManager instantiated inside FlowGraphV2.
*/
export class GroupDisplayState {
#getGroups: () => FlowGroup[]
#runtimeCollapsedIds = $state<Set<string>>(new Set())
#runtimeInitialized = $state(false)
#noteHeights = $state<Record<string, number>>({})
renderCount = $state(0)
constructor(getGroups: () => FlowGroup[]) {
this.#getGroups = getGroups
}
/** Initialize runtime state from autocollapse. Safe to call from event handlers. */
private ensureRuntimeInitialized(): void {
if (this.#runtimeInitialized) return
const groups = this.#getGroups()
this.#runtimeCollapsedIds = new Set(
groups.filter((g) => g.autocollapse).map((g) => groupKey(g))
)
this.#runtimeInitialized = true
}
/** Check if a group is currently collapsed (runtime). Safe to call from $derived. */
isRuntimeCollapsed(groupId: string): boolean {
if (!this.#runtimeInitialized) {
return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false
}
return this.#runtimeCollapsedIds.has(groupId)
}
/** Toggle runtime collapse (Minimize2 button) */
toggleRuntimeCollapse(groupId: string): void {
this.ensureRuntimeInitialized()
const next = new Set(this.#runtimeCollapsedIds)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
this.#runtimeCollapsedIds = next
this.render()
}
/** Expand a group at runtime (CollapsedGroupNode click) */
expandGroup(groupId: string): void {
this.ensureRuntimeInitialized()
const next = new Set(this.#runtimeCollapsedIds)
next.delete(groupId)
this.#runtimeCollapsedIds = next
this.render()
}
/** Set note height for a group (used for layout spacing) */
setNoteHeight(groupId: string, height: number): void {
if (this.#noteHeights[groupId] !== height) {
this.#noteHeights[groupId] = height
this.render()
}
}
/** Get all note heights */
getNoteHeights(): Record<string, number> {
return this.#noteHeights
}
/** Bump render counter to trigger re-layout */
render(): void {
this.renderCount++
}
/** Remap runtime state when a group's boundaries (and thus its key) change */
remapGroupKey(oldKey: string, newKey: string): void {
if (this.#runtimeCollapsedIds.has(oldKey)) {
const next = new Set(this.#runtimeCollapsedIds)
next.delete(oldKey)
next.add(newKey)
this.#runtimeCollapsedIds = next
}
if (oldKey in this.#noteHeights) {
this.#noteHeights[newKey] = this.#noteHeights[oldKey]
delete this.#noteHeights[oldKey]
}
}
/** Get currently collapsed groups for graph builder. Safe to call from $derived. */
getCollapsedGroups(): FlowGroup[] {
if (!this.#runtimeInitialized) {
return this.#getGroups().filter((g) => g.autocollapse)
}
return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g)))
}
}
/**
* Utility class for editing flow groups via direct flowStore mutations.
* Follows the same pattern as NoteEditor.
*/
export class GroupEditor {
private flowStore: StateStore<ExtendedOpenFlow>
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
this.flowStore = flowStore
}
getGroups(): FlowGroup[] {
return this.flowStore.val.value?.groups || []
}
private setGroups(groups: FlowGroup[]): void {
if (this.flowStore.val.value) {
this.flowStore.val.value.groups = groups
}
}
/** IDs that cannot be part of a group (preprocessor, failure module) */
getExcludeIds(): Set<string> {
const excludeIds = new Set<string>()
const pp = this.flowStore.val.value?.preprocessor_module?.id
if (pp) excludeIds.add(pp)
const fm = this.flowStore.val.value?.failure_module?.id
if (fm) excludeIds.add(fm)
return excludeIds
}
/** Check whether the given selection can form a valid group */
canCreateGroup(
selectedIds: string[],
flowNodes: { id: string; parentIds?: string[] }[]
): boolean {
const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds())
if (!result.valid) return false
// Reject if a group with the same boundaries already exists
return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId)
}
/**
* Create a new group from selected node IDs.
* Uses canFormValidGroup to determine start_id and end_id.
* Returns the generated group ID.
*/
createGroup(
moduleIds: string[],
flowNodes: { id: string; parentIds?: string[] }[]
): string | undefined {
// Filter subflow node IDs (same logic as NoteEditor.createGroupNote)
let filteredIds = [...moduleIds]
const subflowIds: string[] = []
for (const id of moduleIds) {
if (id.startsWith('subflow:')) {
const match = id.match(/^subflow:([^:]+)/)
if (match) {
subflowIds.push(match[1])
}
}
}
if (subflowIds.length > 0) {
filteredIds = filteredIds.filter((id) => !subflowIds.includes(id))
filteredIds = [...filteredIds, ...subflowIds]
}
const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds())
if (!result.valid) return undefined
const groups = this.getGroups()
// Reject duplicate: a group with the same boundaries already exists
if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) {
return undefined
}
const usedColors = new Set<NoteColor>()
for (const group of groups) {
if (group.color) {
usedColors.add(group.color as NoteColor)
}
}
const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR
const newGroup: FlowGroup = {
start_id: result.startId,
end_id: result.endId,
color
}
this.setGroups([...groups, newGroup])
return groupKey(newGroup)
}
deleteGroup(groupId: string): void {
const groups = this.getGroups()
this.setGroups(groups.filter((g) => groupKey(g) !== groupId))
}
updateColor(groupId: string, color: NoteColor): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g)))
}
updateSummary(groupId: string, summary: string): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g)))
}
updateNote(groupId: string, note: string | undefined): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g)))
}
/** Add a note to a group (sets note to empty string to trigger the placeholder UI) */
addNote(groupId: string): void {
this.updateNote(groupId, '')
}
/** Remove a note from a group */
removeNote(groupId: string): void {
this.updateNote(groupId, undefined)
}
updateAutocollapse(groupId: string, autocollapse: boolean): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g)))
}
}
export type GroupEditorContext = {
groupEditor: GroupEditor
canCreateGroup: StateStore<boolean>
}
const CONTEXT_KEY = 'GroupEditorContext'
export function setGroupEditorContext(
groupEditor: GroupEditor,
canCreateGroup: StateStore<boolean>
): void {
setContext<GroupEditorContext>(CONTEXT_KEY, { groupEditor, canCreateGroup })
}
export function getGroupEditorContext(): GroupEditorContext | undefined {
return getContext<GroupEditorContext | undefined>(CONTEXT_KEY)
}
/** Height of the group header bar */
export const GROUP_HEADER_HEIGHT = 22
/** Extra margin between the header and the first node */
export const GROUP_TOP_MARGIN = 30
export type GraphGroup = FlowGroup & {
id: string
moduleIds: string[]
}
export type ContainerInnerArray = {
get: () => FlowModule[]
set: (v: any) => void
label?: string
}
/** Get inner arrays from a container FlowModule with direct get/set accessors. */
export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] {
const val = mod.value as any
if (val.type === 'forloopflow' || val.type === 'whileloopflow') {
return [
{
get: () => val.modules,
set: (v) => {
val.modules = v
}
}
]
} else if (val.type === 'branchone') {
return [
{
get: () => val.default,
set: (v) => {
val.default = v
},
label: 'Default'
},
...val.branches.map((b: any, i: number) => ({
get: () => b.modules,
set: (v: any) => {
b.modules = v
},
label: b.summary || `Branch ${i + 1}`
}))
]
} else if (val.type === 'branchall') {
return val.branches.map((b: any, i: number) => ({
get: () => b.modules,
set: (v: any) => {
b.modules = v
},
label: b.summary || `Branch ${i + 1}`
}))
}
return []
}
@@ -0,0 +1,181 @@
import { untrack } from 'svelte'
import type { FlowModule } from '$lib/gen'
import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte'
import type { StateStore } from '$lib/utils'
import { getAllModules } from '../flows/flowExplorer'
import { computeGroupModuleIds } from './groupDetectionUtils'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import {
buildStructureTree,
deriveGroupsFromStructure,
applyStructureToModules,
removeEmptyGroups,
findDuplicateGroups,
removeDuplicateGroups,
flattenStructureIds,
type FlowStructureNode
} from './flowStructure'
export type ExtendedOpenFlow = {
value: {
modules: FlowModule[]
groups?: FlowGroup[]
[key: string]: any
}
[key: string]: any
}
/**
* Reactive read-only view of the flow structure tree.
* The tree is always derived from flowStore (single source of truth).
* Mutations go through prepareMutation: snapshot mutate clean empty groups commit.
*/
export class GroupedModulesProxy {
#items = $state<FlowStructureNode[]>([])
#error = $state<unknown>(undefined)
#flowStore: StateStore<ExtendedOpenFlow>
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
this.#flowStore = flowStore
this.rebuild()
// Rebuild tree whenever store changes (undo/load/mutation)
$effect(() => {
void flowStore.val.value.modules
void flowStore.val.value.groups
untrack(() => this.rebuild())
})
}
/** Reactive access to the structure tree (read-only view) */
get items(): FlowStructureNode[] {
return this.#items
}
/** Reactive access to build errors */
get error(): unknown {
return this.#error
}
/**
* Prepare a structural mutation without writing to the store yet.
* Returns the list of groups that became empty (already removed from the snapshot)
* and a `commit` function that writes the result to the store.
*
* If no groups were emptied, the caller can commit immediately.
* If groups were emptied, the caller should show a confirmation modal
* and call commit() only on user confirmation.
*/
prepareMutation(
mutate: (tree: FlowStructureNode[]) => void,
opts?: {
extraModules?: FlowModule[]
displayState?: import('./groupEditor.svelte').GroupDisplayState
}
): {
emptiedGroups: FlowGroup[]
duplicateGroups: FlowGroup[]
commit: (commitOpts?: { removeDuplicates?: boolean }) => void
} {
const snapshot = $state.snapshot(this.#items) as FlowStructureNode[]
mutate(snapshot)
// Clean up empty groups and collect which ones were removed
const emptiedGroups = removeEmptyGroups(snapshot)
// Detect groups that became duplicates after the mutation
const duplicateGroups = findDuplicateGroups(snapshot)
const commit = (commitOpts?: { removeDuplicates?: boolean }) => {
if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) {
removeDuplicateGroups(snapshot)
}
// Remap runtime state for groups whose boundaries shifted
if (opts?.displayState) {
this.#remapChangedGroupKeys(snapshot, opts.displayState)
}
// Build moduleMap lazily at commit time so it reflects the latest store state
const moduleMap = new Map<string, FlowModule>()
for (const m of getAllModules(this.#flowStore.val.value.modules)) {
moduleMap.set(m.id, m)
}
if (opts?.extraModules) {
for (const m of opts.extraModules) {
moduleMap.set(m.id, m)
}
}
this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap)
this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot)
}
return { emptiedGroups, duplicateGroups, commit }
}
/**
* Convenience: prepare + auto-commit. Only use for mutations that cannot
* empty groups (e.g. inserts). Throws if groups are unexpectedly emptied.
* For mutations that may empty groups, use prepareMutation() directly.
*/
applyTreeMutation(
mutate: (tree: FlowStructureNode[]) => void,
opts?: {
extraModules?: FlowModule[]
displayState?: import('./groupEditor.svelte').GroupDisplayState
}
): void {
const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts)
if (emptiedGroups.length > 0) {
console.error('applyTreeMutation: unexpected empty groups', emptiedGroups)
}
if (duplicateGroups.length > 0) {
console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups)
}
commit()
}
/** Remap runtime state for group nodes whose boundaries shifted after a mutation. */
#remapChangedGroupKeys(
snapshot: FlowStructureNode[],
displayState: import('./groupEditor.svelte').GroupDisplayState
): void {
const walk = (nodes: FlowStructureNode[]) => {
for (const node of nodes) {
if (node.kind === 'group') {
const oldKey = node.id
const flatIds = flattenStructureIds(node.branches[0].children)
const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null
if (newKey && oldKey !== newKey) {
displayState.remapGroupKey(oldKey, newKey)
}
walk(node.branches[0].children)
} else {
for (const branch of node.branches) {
walk(branch.children)
}
}
}
}
walk(snapshot)
}
/** Rebuild from flowStore */
private rebuild(): void {
const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[]
const allGroups = this.#flowStore.val.value.groups ?? []
const allModules = getAllModules(modules)
const graphGroups: GraphGroup[] = allGroups.map((g) => ({
...g,
id: groupKey(g),
moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules)
}))
try {
this.#items = buildStructureTree(modules, graphGroups)
this.#error = undefined
} catch (e) {
// Intentionally preserve last-known-good #items so the graph
// can still render while the error is surfaced to the user.
this.#error = e
}
}
}
@@ -204,9 +204,6 @@ export class MoveManager {
for (const [edgeId, zone] of this.#registeredDropZones) {
if (zone.disableMoveIds.includes(draggedId)) continue
// Skip edges adjacent to the dragged node (no-op move)
if (zone.sourceId === draggedId || zone.targetId === draggedId) continue
const dx = Math.abs(flowPos.x - zone.centerX)
const dy = Math.abs(flowPos.y - zone.centerY)
@@ -0,0 +1,153 @@
import type { FlowNote } from '../../gen'
import type { AssetWithAltAccessType } from '../assets/lib'
import {
assetDisplaysAsInputInFlowGraph,
assetDisplaysAsOutputInFlowGraph,
NODE_WITH_READ_ASSET_Y_OFFSET,
NODE_WITH_WRITE_ASSET_Y_OFFSET
} from './renderers/nodes/AssetNode.svelte'
import {
AI_TOOL_BASE_OFFSET,
AI_TOOL_ROW_OFFSET,
BELOW_ADDITIONAL_OFFSET
} from './renderers/nodes/AIToolNode.svelte'
import { topologicalSort } from './graphBuilder.svelte'
import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte'
import type { GroupDisplayState } from './groupEditor.svelte'
import type { GraphModuleState } from '.'
type NodeDep = {
id: string
parentIds?: string[]
data?: { assets?: AssetWithAltAccessType[]; module?: any }
}
type ExtraSpace = { top: number; bottom: number; left: number; right: number }
const MAX_TOOLS_PER_ROW = 2
/**
* Pre-compute extra top/bottom space each node needs for decorations
* (assets, AI tools, group headers, group notes).
*/
export function computeNodeExtraSpace(
graphNodes: NodeDep[],
opts: {
showAssets: boolean
showNotes: boolean
notes: FlowNote[] | undefined
noteTextHeights: Record<string, number>
groupDisplayState: GroupDisplayState
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
}
): Map<string, ExtraSpace> | undefined {
const extraSpace = new Map<string, ExtraSpace>()
// 1. Assets
if (opts.showAssets) {
for (const node of graphNodes) {
const assets = node.data?.assets ?? []
if (!assets.length) continue
const hasRead = assets.some(assetDisplaysAsInputInFlowGraph)
const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph)
if (hasRead || hasWrite) {
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, {
...prev,
top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0),
bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
})
}
}
}
// 2. AI tools
for (const node of graphNodes) {
const mod = node.data?.module
if (!mod || mod.value?.type !== 'aiagent') continue
const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions
if (agentActions) {
// Execution mode: tools below
const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW)
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space })
} else {
// Edit mode: tools above
const tools = mod.value.tools ?? []
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0)
const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, { ...prev, top: prev.top + space })
}
}
// Topological sort (reversed: top-of-graph first) — shared by group notes and group headers
const sortedNodes = topologicalSort(graphNodes).reverse()
// 3. Group notes (text above topmost node in each group note)
if (opts.showNotes) {
const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group')
if (groupNotes.length > 0) {
for (const groupNote of groupNotes) {
if (!groupNote.contained_node_ids?.length) continue
const topmostNodeId = sortedNodes.find((node) =>
groupNote.contained_node_ids?.includes(node.id)
)?.id
if (topmostNodeId) {
const textHeight = opts.noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
const prev = extraSpace.get(topmostNodeId) ?? {
top: 0,
bottom: 0,
left: 0,
right: 0
}
extraSpace.set(topmostNodeId, {
...prev,
top: Math.max(prev.top, spacing + prev.top)
})
}
}
}
}
// 4. Collapsed group nodes are taller than regular nodes (header + module icons)
for (const node of graphNodes) {
if (node.id.startsWith('collapsed-group:')) {
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, {
...prev,
bottom: prev.bottom + GROUP_HEADER_HEIGHT
})
}
}
// 5. Group nodes (expanded heads and collapsed) with notes need extra height
if (opts.showNotes) {
const noteHeights = opts.groupDisplayState.getNoteHeights()
for (const node of graphNodes) {
let groupId: string | undefined
if (node.id.startsWith('group:') && !node.id.endsWith('-end')) {
groupId = node.id.slice('group:'.length)
} else if (node.id.startsWith('collapsed-group:')) {
groupId = node.id.slice('collapsed-group:'.length)
}
if (groupId) {
const noteHeight = noteHeights[groupId]
if (noteHeight && noteHeight > 0) {
const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 }
extraSpace.set(node.id, {
...prev,
bottom: prev.bottom + noteHeight
})
}
}
}
}
return extraSpace.size > 0 ? extraSpace : undefined
}
+21 -10
View File
@@ -14,6 +14,7 @@ export enum NoteColor {
export interface NoteColorConfig {
background: string
backgroundLight: string
outline: string
outlineHover: string
text: string
@@ -24,70 +25,80 @@ export interface NoteColorConfig {
export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
[NoteColor.YELLOW]: {
background: 'bg-yellow-200 dark:bg-yellow-900',
outline: 'outline-yellow-300 dark:outline-yellow-600',
backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5',
outline: 'outline-yellow-200 dark:outline-yellow-900',
outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60',
text: 'text-yellow-900 dark:text-yellow-100',
hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800'
},
[NoteColor.BLUE]: {
background: 'bg-blue-100 dark:bg-blue-950',
outline: 'outline-blue-300 dark:outline-blue-600',
backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5',
outline: 'outline-blue-100 dark:outline-blue-950',
outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60',
text: 'text-blue-900 dark:text-blue-100',
hover: 'hover:bg-blue-200 dark:hover:bg-blue-800'
},
[NoteColor.GREEN]: {
background: 'bg-green-200 dark:bg-green-900',
outline: 'outline-green-300 dark:outline-green-600',
backgroundLight: 'bg-green-400/5 dark:bg-green-600/5',
outline: 'outline-green-200 dark:outline-green-900',
outlineHover: 'outline-green-300/60 dark:outline-green-600/60',
text: 'text-green-900 dark:text-green-100',
hover: 'hover:bg-green-200 dark:hover:bg-green-800'
},
[NoteColor.PURPLE]: {
background: 'bg-purple-200 dark:bg-purple-900',
outline: 'outline-purple-300 dark:outline-purple-600',
backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5',
outline: 'outline-purple-200 dark:outline-purple-900',
outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60',
text: 'text-purple-900 dark:text-purple-100',
hover: 'hover:bg-purple-200 dark:hover:bg-purple-800'
},
[NoteColor.PINK]: {
background: 'bg-pink-200 dark:bg-pink-900',
outline: 'outline-pink-300 dark:outline-pink-600',
backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5',
outline: 'outline-pink-200 dark:outline-pink-900',
outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60',
text: 'text-pink-900 dark:text-pink-100',
hover: 'hover:bg-pink-200 dark:hover:bg-pink-800'
},
[NoteColor.ORANGE]: {
background: 'bg-orange-200 dark:bg-orange-900',
outline: 'outline-orange-300 dark:outline-orange-600',
backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5',
outline: 'outline-orange-200 dark:outline-orange-900',
outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60',
text: 'text-orange-900 dark:text-orange-100',
hover: 'hover:bg-orange-200 dark:hover:bg-orange-800'
},
[NoteColor.RED]: {
background: 'bg-red-200 dark:bg-red-900',
outline: 'outline-red-300 dark:outline-red-600',
backgroundLight: 'bg-red-400/5 dark:bg-red-600/5',
outline: 'outline-red-200 dark:outline-red-900',
outlineHover: 'outline-red-300/60 dark:outline-red-600/60',
text: 'text-red-900 dark:text-red-100',
hover: 'hover:bg-red-200 dark:hover:bg-red-800'
},
[NoteColor.CYAN]: {
background: 'bg-cyan-200 dark:bg-cyan-900',
outline: 'outline-cyan-300 dark:outline-cyan-600',
backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5',
outline: 'outline-cyan-200 dark:outline-cyan-900',
outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60',
text: 'text-cyan-900 dark:text-cyan-100',
hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800'
},
[NoteColor.LIME]: {
background: 'bg-lime-200 dark:bg-lime-900',
outline: 'outline-lime-300 dark:outline-lime-600',
backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5',
outline: 'outline-lime-200 dark:outline-lime-900',
outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60',
text: 'text-lime-900 dark:text-lime-100',
hover: 'hover:bg-lime-200 dark:hover:bg-lime-800'
},
[NoteColor.GRAY]: {
background: 'bg-gray-200 dark:bg-gray-800',
outline: 'outline-gray-300 dark:outline-gray-600',
backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5',
outline: 'outline-gray-200 dark:outline-gray-800',
outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60',
text: 'text-gray-900 dark:text-gray-100',
hover: 'hover:bg-gray-200 dark:hover:bg-gray-700'
@@ -19,20 +19,6 @@ export type NodeDep = {
export type NoteComputeResult = {
noteNodes: (Node & NodeLayout)[]
newNodePositions: Record<string, { x: number; y: number }>
}
export type AIToolSpacingInfo = {
toolNodes: (Node & NodeLayout)[]
toolEdges: any[]
newNodePositions: Record<string, { x: number; y: number }>
}
export interface GroupNoteBounds {
x: number
y: number
width: number
height: number
}
let computeNoteNodesCache:
@@ -283,14 +269,9 @@ export function computeNoteNodes(
const allNoteNodes: (Node & NodeLayout)[] = []
// Build a map of Y positions that need extra spacing for group notes
const yPosMap: Record<number, number> = {} // Y position -> spacing needed
// Group notes that need spacing
// Find topmost node per group note for layout calculation
const groupNotes = notes.filter((n) => n.type === 'group')
const topMostNodesMap: Record<string, string> = {}
const sortedNodes = topologicalSort(nodes).reverse()
for (const groupNote of groupNotes) {
@@ -298,47 +279,12 @@ export function computeNoteNodes(
const topmostNodeId = sortedNodes.find((node) =>
groupNote.contained_node_ids?.includes(node.id)
)?.id
const topmostNode = nodes.find((node) => node.id === topmostNodeId)
if (topmostNode) {
const textHeight = noteTextHeights[groupNote.id] || 60
const spacing = textHeight + 16 // padding
// Mark this Y position as needing spacing
yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing)
topMostNodesMap[groupNote.id] = topmostNode.id
if (topmostNodeId) {
topMostNodesMap[groupNote.id] = topmostNodeId
}
}
}
// Calculate new positions for nodes (offset by group notes)
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
// Add spacing for group notes at this Y level
if (yPosMap[node.position.y]) {
currentYOffset += yPosMap[node.position.y]
}
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
// Create note nodes AFTER calculating adjusted node positions
// For group notes, we need to use the adjusted node positions
const adjustedNodes = sortedNewNodes.map((n) => {
const origNode = nodes.find((orig) => orig.id === n.id)
return {
...n,
data: origNode?.data,
type: origNode?.type
}
})
// Calculate all z-indexes at once using hierarchy information
const noteZIndexes = calculateAllNoteZIndexes(notes, nodes)
@@ -346,11 +292,11 @@ export function computeNoteNodes(
const isGroupNote = note.type === 'group'
const zIndex = noteZIndexes[note.id]
// Calculate position and size using adjusted node positions for group notes
// Calculate position and size using node positions for group notes
const { position, size } = isGroupNote
? calculateGroupNoteLayout(
note,
adjustedNodes,
nodes,
noteTextHeights[note.id] || 60,
topMostNodesMap[note.id]
)
@@ -375,13 +321,8 @@ export function computeNoteNodes(
allNoteNodes.push(noteNode)
}
const newNodePositions: Record<string, { x: number; y: number }> = Object.fromEntries(
sortedNewNodes.map((n) => [n.id, n.position])
)
const result: NoteComputeResult = {
noteNodes: allNoteNodes,
newNodePositions
noteNodes: allNoteNodes
}
// Cache the result
@@ -12,11 +12,14 @@
import type { GraphModuleState } from '../../model'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import { getGraphContext } from '../../graphContext'
import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout'
const { useDataflow, showAssets, moveManager } = getGraphContext()
let {
id,
source,
target,
sourceX,
sourceY,
sourcePosition,
@@ -45,6 +48,13 @@
}
} = $props()
// Derive group boundary from source/target node IDs
let groupBoundary: 'top' | 'bottom' | undefined = $derived.by(() => {
if (source.startsWith('group:') && !source.endsWith('-end')) return 'top'
if (target.startsWith('group:') && target.endsWith('-end')) return 'bottom'
return undefined
})
let [edgePath] = $derived(
getBezierPath({
sourceX,
@@ -75,9 +85,15 @@
)
let centerY = $derived(
sourceY +
32 +
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
groupBoundary === 'bottom'
? targetY
: groupBoundary === 'top'
? sourceY + GROUP_TOP_PADDING / 2
: sourceY +
32 +
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets
? NODE_WITH_WRITE_ASSET_Y_OFFSET
: 0)
)
let isDragging = $derived(!!moveManager?.dragging)
@@ -87,13 +103,13 @@
data?.insertable &&
draggedId !== undefined &&
!data.disableMoveIds?.includes(draggedId) &&
data.sourceId !== draggedId &&
data.targetId !== draggedId
source !== draggedId &&
target !== draggedId
)
let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id)
let isAdjacentToDragged = $derived(
isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId)
let isNearestDrop = $derived(
isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id ? true : false
)
let isAdjacentToDragged = $derived(isDragging && (source === draggedId || target === draggedId))
// Register this edge's drop zone position with the drag manager so proximity
// detection uses the actual xyflow-computed position rather than re-deriving it.
@@ -161,7 +177,7 @@
{@render dropTargetIndicator(isNearestDrop)}
</div>
</div>
{:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
{:else if data?.insertable && !groupBoundary && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
style:transform="translate(-50%, -50%)"
@@ -213,7 +229,7 @@
{#if moveManager?.movingModuleId && data?.insertable}
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
{#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))}
{#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some( (id) => data.disableMoveIds?.includes(id) )}
<button
title="Paste module"
onclick={() => {
@@ -252,7 +268,7 @@
<BaseEdge
path={completeEdge}
{markerEnd}
class={$useDataflow ? 'hidden' : isAdjacentToDragged ? 'opacity-30' : ''}
class={$useDataflow || groupBoundary ? 'hidden' : isAdjacentToDragged ? 'opacity-30' : ''}
interactionWidth={0}
style={undefined}
label={undefined}
@@ -75,7 +75,6 @@
): {
toolNodes: (Node & NodeLayout)[]
toolEdges: Edge[]
newNodePositions: Record<string, { x: number; y: number }>
} {
if (
computeAIToolNodesCache &&
@@ -88,14 +87,6 @@
const allToolNodes: (Node & NodeLayout)[] = []
const allToolEdges: Edge[] = []
const yPosMap: Record<
number,
{
rows: number
placement: 'above' | 'below'
}
> = {}
for (const node of nodes) {
if (node.type !== 'module' || node.data.module.value.type !== 'aiagent') continue
// by default we assume we will show tools above
@@ -154,17 +145,6 @@
}
const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (insertable ? 1 : 0) // + 1 for add tool node when insertable
if (agentActions) {
yPosMap[node.position.y] = {
rows: totalRows,
placement: 'below'
}
} else {
yPosMap[node.position.y] = {
rows: totalRows,
placement: 'above'
}
}
const siblingNames = tools.map((t) => t.name)
const toolNodes: (Node & AiToolN)[] = tools.map((tool, i) => {
@@ -236,36 +216,9 @@
}
}
const sortedNewNodes = nodes
.filter((n) => n.type !== 'asset')
.map((n) => ({ id: n.id, position: $state.snapshot(n.position) }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
// if agent actions, we need to shift the node above
if (yPosMap[prevYPos]?.placement === 'below') {
currentYOffset += AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * yPosMap[prevYPos].rows
}
if (yPosMap[node.position.y]?.placement === 'above') {
currentYOffset += AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * yPosMap[node.position.y].rows
}
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAIToolNodes> = {
toolNodes: allToolNodes,
toolEdges: allToolEdges,
newNodePositions: Object.fromEntries(
sortedNewNodes.map((n) => {
return [n.id, n.position]
})
)
toolEdges: allToolEdges
}
computeAIToolNodesCache = {
@@ -19,8 +19,6 @@
export function computeAssetNodes(nodes: NodeDep[]): {
newAssetNodes: (Node & NodeLayout)[]
newAssetEdges: Edge[]
// Nodes need to be offset on the y axis to make space for the asset nodes
newNodePositions: Record<string, { x: number; y: number }>
} {
if (computeAssetNodesCache && deepEqual(nodes, computeAssetNodesCache[0])) {
return computeAssetNodesCache[1]
@@ -30,8 +28,6 @@
const allAssetNodes: (Node & NodeLayout)[] = []
const allAssetEdges: Edge[] = []
const yPosMap: Record<number, { r?: true; w?: true }> = {}
for (const node of nodes) {
const assets = node.data.assets ?? []
if (!assets.length) continue
@@ -47,13 +43,6 @@
const overflowedInputAssets = inputAssets.slice(3)
const overflowedOutputAssets = outputAssets.slice(3)
// This allows calculating which nodes to offset on the y axis to
// make space for the asset nodes
if (inputAssets.length || outputAssets.length)
yPosMap[node.position.y] = yPosMap[node.position.y] ?? {}
if (inputAssets.length) yPosMap[node.position.y].r = true
if (outputAssets.length) yPosMap[node.position.y].w = true
// All asset nodes displayed on top
const inputAssetNodes: (Node & AssetN)[] = displayedInputAssets.map((asset, i) => {
let inputAssetXGap = 12
@@ -187,26 +176,9 @@
})
}
// Shift all nodes to make space for the new asset nodes
const sortedNewNodes = nodes
.map((n) => ({ position: { ...n.position }, id: n.id }))
.sort((a, b) => a.position.y - b.position.y)
let currentYOffset = 0
let prevYPos = NaN
for (const node of sortedNewNodes) {
if (node.position.y !== prevYPos) {
if (yPosMap[prevYPos]?.w) currentYOffset += NODE_WITH_WRITE_ASSET_Y_OFFSET
if (yPosMap[node.position.y]?.r) currentYOffset += NODE_WITH_READ_ASSET_Y_OFFSET
prevYPos = node.position.y
}
node.position.y += currentYOffset
}
let ret: ReturnType<typeof computeAssetNodes> = {
newAssetNodes: allAssetNodes,
newAssetEdges: allAssetEdges,
newNodePositions: Object.fromEntries(sortedNewNodes.map((n) => [n.id, n.position]))
newAssetEdges: allAssetEdges
}
computeAssetNodesCache = [clone(nodes), ret]
return ret
@@ -0,0 +1,106 @@
<script lang="ts">
import NodeWrapper from './NodeWrapper.svelte'
import type { CollapsedGroupN } from '../../graphBuilder.svelte'
import GroupModuleIcons from '../../GroupModuleIcons.svelte'
import GroupHeaderBlock from '../../GroupHeaderBlock.svelte'
import { NoteColor, NOTE_COLORS } from '../../noteColors'
import { NODE } from '../../util'
import { Hourglass } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import { dfs } from '$lib/components/flows/dfs'
interface Props {
data: CollapsedGroupN['data']
id: string
}
let { data, id }: Props = $props()
let outlineColorClass = $derived(
(NOTE_COLORS[(data.color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE])
.outline
)
let bgColorClass = $derived(
NOTE_COLORS[(data.color as NoteColor) ?? NoteColor.BLUE]?.backgroundLight ??
NOTE_COLORS[NoteColor.BLUE].backgroundLight
)
let allModuleIds = $derived(data.modules ? dfs(data.modules, (m) => m.id) : [])
let waitingForEvents = $derived(
allModuleIds.some(
(mid) =>
data.flowModuleStates?.[mid]?.type === 'WaitingForEvents' ||
data.flowModuleStates?.[`${mid}-v`]?.type === 'WaitingForEvents'
)
)
</script>
<NodeWrapper nodeId={id}>
<div class="relative">
<div
class="w-full max-w-full rounded-lg outline outline-1 -outline-offset-1 {outlineColorClass} {bgColorClass}"
style="width: {NODE.width}px;"
>
<GroupHeaderBlock
groupId={data.groupId}
summary={data.summary}
note={data.note}
color={data.color}
collapsed={true}
autocollapse={data.autocollapse ?? false}
editMode={data.editMode}
showNotes={data.showNotes}
/>
{#if data.modules && data.modules.length > 0}
<div class="flex items-center justify-center w-full gap-1.5 px-2 h-[34px] overflow-hidden">
<GroupModuleIcons
modules={data.modules}
flowModuleStates={data.flowModuleStates}
eventHandlers={data.eventHandlers}
/>
</div>
{/if}
</div>
{#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'}
<div
class="absolute top-1/2 -translate-y-1/2 left-full ml-2 flex items-center gap-2 nodrag nowheel"
>
<div
class="px-2 py-0.5 rounded-md bg-surface shadow-md text-violet-700 dark:text-violet-400 text-xs flex items-center gap-1"
>
<Hourglass size={12} />
<div class="flex">
<span class="dot">.</span>
<span class="dot">.</span>
<span class="dot">.</span>
</div>
</div>
<div class="rounded-md bg-surface flex items-center justify-center p-2 shadow-md">
{#if data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents
job={data.flowJob}
workspaceId={$workspaceStore!}
isOwner={data.isOwner}
light
/>
{:else if data.suspendStatus && Object.keys(data.suspendStatus).length > 0}
<div class="flex gap-2 flex-col">
{#each Object.values(data.suspendStatus) as suspendCount (suspendCount.job.id)}
<FlowStatusWaitingForEvents
job={suspendCount.job}
workspaceId={$workspaceStore!}
isOwner={data.isOwner}
light
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</NodeWrapper>
@@ -39,14 +39,15 @@
if (!selectedId) return 'none'
// Check direct children
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (module.value.modules.some((m) => m.id === selectedId)) {
const children = module.value.modules
if (children.some((m) => m.id === selectedId)) {
return 'child'
}
// Check grandchildren
return module.value.modules.some(
return children.some(
(m) =>
(m.value.type === 'forloopflow' || m.value.type === 'whileloopflow') &&
m.value.modules.some((gm) => gm.id === selectedId)
m.value.modules.some((gm: FlowModule) => gm.id === selectedId)
)
? 'grandchild'
: 'none'
@@ -0,0 +1,15 @@
<script lang="ts">
import { NODE } from '../../util'
import NodeWrapper from './NodeWrapper.svelte'
interface Props {
id: string
}
let { id }: Props = $props()
</script>
<!-- Invisible layout node for group end boundary. -->
<NodeWrapper nodeId={id}>
<div style="width: {NODE.width}px; height: 1px;"></div>
</NodeWrapper>
@@ -0,0 +1,28 @@
<script lang="ts">
import { NODE } from '../../util'
import NodeWrapper from './NodeWrapper.svelte'
import GroupHeaderBlock from '../../GroupHeaderBlock.svelte'
import type { GroupHeadN } from '../../graphBuilder.svelte'
interface Props {
data: GroupHeadN['data']
id: string
}
let { data, id }: Props = $props()
</script>
<NodeWrapper enableSourceHandle enableTargetHandle nodeId={id}>
<div style="width: {NODE.width}px;">
<GroupHeaderBlock
groupId={data.groupId}
summary={data.summary}
note={data.note}
color={data.color}
collapsed={false}
autocollapse={data.autocollapse ?? false}
editMode={data.editMode}
showNotes={data.showNotes}
/>
</div>
</NodeWrapper>
@@ -44,7 +44,7 @@
// Define context menu items
let noteDisabled = $derived(
!noteEditorContext?.noteEditor ||
(noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(data.id) ?? false)
(noteEditorContext?.noteEditor?.isNodeOnlyMemberOfGroupNote(data.id) ?? false)
)
let isPreprocessor = $derived(data.id === 'preprocessor')
@@ -138,7 +138,10 @@
isOwner={data.isOwner}
maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value
? () => {
const path = data.module?.value && 'path' in data.module.value ? data.module.value['path'] as string : undefined
const path =
data.module?.value && 'path' in data.module.value
? (data.module.value['path'] as string)
: undefined
if (path) {
data.eventHandlers.expandSubflow(data.id, path)
}
@@ -146,8 +149,8 @@
: undefined}
/>
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
{#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable}
{#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable}
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10 flex gap-1">
<button
title="Add branch"
class="rounded text-secondary border hover:bg-surface-hover bg-surface p-1"
@@ -157,7 +160,7 @@
>
<GitBranchPlus size={16} />
</button>
{/if}
</div>
</div>
{/if}
{/snippet}
</NodeWrapper>
@@ -53,7 +53,7 @@
<div style={`width: ${NODE.width}px;`} use:floatingRef>
<button
class="relative flex w-full flex-row gap-1.5 px-2 p-1 items-center justify-center rounded-md drop-shadow-base {colorClasses.outline} {colorClasses.bg}"
class="relative flex w-full flex-row gap-1.5 px-2 p-1 items-center justify-center rounded-md drop-shadow-sm {colorClasses.outline} {colorClasses.bg}"
style="height: {NODE.height}px"
onclick={() => dispatch('select')}
>
+10 -5
View File
@@ -227,6 +227,7 @@ export function calculateNodesBoundsWithOffset(
id: string
position: { x: number; y: number }
type: string
measured?: { width?: number; height?: number }
}>
): {
minX: number
@@ -239,11 +240,13 @@ export function calculateNodesBoundsWithOffset(
return nodesToCalculate.reduce(
(acc, node) => {
const w = node.measured?.width ?? NODE.width
const h = node.measured?.height ?? NODE.height
return {
minX: Math.min(acc.minX, node.position.x),
minY: Math.min(acc.minY, node.position.y),
maxX: Math.max(acc.maxX, node.position.x + NODE.width),
maxY: Math.max(acc.maxY, node.position.y + NODE.height)
maxX: Math.max(acc.maxX, node.position.x + w),
maxY: Math.max(acc.maxY, node.position.y + h)
}
},
{
@@ -267,17 +270,19 @@ function getAllRelatedSubflowNodes(
id: string
position: { x: number; y: number }
type: string
measured?: { width?: number; height?: number }
}>
): Array<{
id: string
position: { x: number; y: number }
measured?: { width?: number; height?: number }
}> {
const relatedNodeIds = new Set<string>()
// Add original target nodes
targetNodeIds.forEach((id) => relatedNodeIds.add(id))
// For each target node, check if it's a subflow and find expanded nodes
// For each target node, check if it's a subflow or container and find child nodes
targetNodeIds.forEach((nodeId) => {
// Find nodes like "subflow:{nodeId}:*"
const subflowNodes = allNodes.filter(
@@ -301,6 +306,6 @@ function getAllRelatedSubflowNodes(
* Generate a random unique ID for notes
* @returns A random string ID
*/
export function generateId(): string {
return 'note-' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)
export function generateId(prefix: string = 'note-'): string {
return prefix + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)
}
@@ -307,6 +307,7 @@
earlyStop={job.raw_flow?.skip_expr !== undefined}
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules}
groups={job.raw_flow?.groups}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
notSelectable
+2 -1
View File
@@ -4,7 +4,7 @@
import { decodeState } from '$lib/utils'
let content = localStorage.getItem('svelvet')
const { modules, failureModule, preprocessorModule, notes } = content
const { modules, failureModule, preprocessorModule, notes, groups } = content
? decodeState(content)
: { modules: [], failureModule: undefined, preprocessorModule: undefined }
</script>
@@ -16,6 +16,7 @@
{failureModule}
{preprocessorModule}
{notes}
{groups}
/>
<a
download="flow.json"
+32
View File
@@ -112,6 +112,11 @@ components:
description: Sticky notes attached to the flow
items:
$ref: '#/components/schemas/FlowNote'
groups:
type: array
description: Semantic groups of modules for organizational purposes
items:
$ref: '#/components/schemas/FlowGroup'
required:
- modules
@@ -209,6 +214,33 @@ components:
- color
- type
FlowGroup:
type: object
description: A semantic group of flow modules for organizational purposes. Does not affect execution — modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.
properties:
summary:
type: string
description: Display name for this group
note:
type: string
description: Markdown note shown below the group header
autocollapse:
type: boolean
default: false
description: If true, this group is collapsed by default in the flow editor. UI hint only.
start_id:
type: string
description: ID of the first flow module in this group (topological entry point)
end_id:
type: string
description: ID of the last flow module in this group (topological exit point)
color:
type: string
description: Color for the group in the flow editor
required:
- start_id
- end_id
RetryIf:
type: object
description: Conditional retry based on error or result
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long