feat: collapse groups as single graph nodes like subflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-03-04 14:45:51 +00:00
co-authored by Claude Opus 4.6
parent b862a3dc62
commit 22182d4fe2
7 changed files with 267 additions and 115 deletions
+16
View File
@@ -178,6 +178,8 @@ pub struct FlowValue {
pub chat_input_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_env: Option<HashMap<String, Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups: Option<Vec<FlowGroup>>,
}
impl FlowValue {
@@ -404,6 +406,20 @@ pub struct Mock {
pub return_value: Option<serde_json::Value>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct FlowGroup {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collapsed: Option<bool>,
pub module_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct FlowModule {
#[serde(default = "default_id")]
@@ -61,6 +61,7 @@
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 NoteTool from './NoteTool.svelte'
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
import GroupOverlay from './GroupOverlay.svelte'
@@ -467,6 +468,9 @@
delete expandedSubflows[id]
expandedSubflows = expandedSubflows
},
expandGroup: (groupId: string) => {
groupEditorContext?.groupEditor.updateCollapsedDefault(groupId, false)
},
updateMock: (detail) => {
onUpdateMock?.(detail)
},
@@ -760,7 +764,8 @@
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode,
note: NoteNode
note: NoteNode,
collapsedGroup: CollapsedGroupNode
} as any
const edgeTypes = {
@@ -796,6 +801,12 @@
let graph = $derived.by(() => {
moduleTracker.counter
effectiveModuleActions
currentGroups
const collapsedGroups = (groupEditorContext?.groupEditor.getGroups() ?? []).filter(
(g) => g.collapsed === true
)
return graphBuilder(
untrack(() => effectiveModules),
{
@@ -828,7 +839,8 @@
untrack(() => selectedId),
simplifiableFlow,
triggerNode ? path : undefined,
expandedSubflows
expandedSubflows,
collapsedGroups
)
})
let hideAssetsToggle = $derived(
@@ -1,7 +1,7 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { calculateNodesBoundsWithOffset } from './util'
import { ChevronDown, ChevronRight, Pen, X } from 'lucide-svelte'
import { ChevronDown, Pen, X } from 'lucide-svelte'
import { getGroupEditorContext, type FlowGroup } from './groupEditor.svelte'
import { NoteColor, NOTE_COLORS } from './noteColors'
import NoteColorPicker from './NoteColorPicker.svelte'
@@ -17,9 +17,6 @@
const groupEditorContext = getGroupEditorContext()
// Runtime collapse state, keyed by group ID
let collapsedState: Record<string, boolean> = $state({})
// Color picker open state
let colorPickerOpen = $state(false)
@@ -44,11 +41,6 @@
hideTimeout = undefined
}
visibleGroup = activeGroup
// Initialize collapse state from persisted value if not already set
if (activeGroup.id && !(activeGroup.id in collapsedState)) {
collapsedState[activeGroup.id] = activeGroup.collapsed ?? false
}
} else if (!colorPickerOpen && !actionBarHovered && !editingSummary) {
hideTimeout = setTimeout(() => {
visibleGroup = undefined
@@ -103,12 +95,12 @@
)
}
function isCollapsed(groupId: string): boolean {
return collapsedState[groupId] ?? false
}
function toggleCollapse(groupId: string) {
collapsedState[groupId] = !isCollapsed(groupId)
const current =
groupEditorContext?.groupEditor
.getGroups()
.find((g) => g.id === groupId)?.collapsed ?? false
groupEditorContext?.groupEditor.updateCollapsedDefault(groupId, !current)
}
// Label hover state (for showing pen button)
@@ -132,118 +124,117 @@
</script>
{#each allGroups as group (group.id)}
{@const bounds = computeGroupBounds(group)}
{#if bounds}
<ViewportPortal target="front">
<!-- Always-visible border (no bg, solid 1px) -->
<div
class="absolute rounded-lg border pointer-events-none {getBorderColorClass(group.color)}"
style:transform="translate({bounds.x}px, {bounds.y}px)"
style:width="{bounds.width}px"
style:height="{bounds.height}px"
style:z-index="4"
>
<!-- Label (top-left, above the border) -->
{#if !group.collapsed}
{@const bounds = computeGroupBounds(group)}
{#if bounds}
<ViewportPortal target="front">
<!-- Always-visible border (no bg, solid 1px) -->
<div
class="absolute -top-6 left-0 flex items-center gap-1 h-5"
style="pointer-events: auto; cursor: default;"
onpointerenter={() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = undefined
}
hoveredLabelGroupId = group.id
visibleGroup = group
if (group.id && !(group.id in collapsedState)) {
collapsedState[group.id] = group.collapsed ?? false
}
}}
onpointerleave={() => {
hoveredLabelGroupId = null
if (!colorPickerOpen && !actionBarHovered && !editingSummary) {
hideTimeout = setTimeout(() => {
visibleGroup = undefined
}, 150)
}
}}
class="absolute rounded-lg border pointer-events-none {getBorderColorClass(group.color)}"
style:transform="translate({bounds.x}px, {bounds.y}px)"
style:width="{bounds.width}px"
style:height="{bounds.height}px"
style:z-index="4"
>
{#if editingGroupId === group.id}
<input
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
bind:value={summaryInput}
onblur={() => commitSummary(group.id)}
onkeydown={(e) => {
if (e.key === 'Enter') commitSummary(group.id)
if (e.key === 'Escape') {
editingGroupId = null
}
}}
autofocus
/>
{:else}
<span class="text-xs font-medium {getTextColorClass(group.color)}">
{group.summary || 'Group'}
</span>
{#if editMode && hoveredLabelGroupId === group.id}
<button
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
onclick={() => startEditSummary(group)}
title="Edit group name"
>
<Pen size={10} />
</button>
{/if}
{/if}
</div>
<!-- Action bar (top-right, hover only) — matches group note style -->
{#if editMode && visibleGroup?.id === group.id}
<!-- Label (top-left, above the border) -->
<div
class="absolute -top-7 right-0 p-1 h-7 group flex justify-end"
style="pointer-events: auto;"
class="absolute -top-6 left-0 flex items-center gap-1 h-5"
style="pointer-events: auto; cursor: default;"
onpointerenter={() => {
actionBarHovered = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = undefined
}
hoveredLabelGroupId = group.id
visibleGroup = group
}}
onpointerleave={() => {
actionBarHovered = false
hoveredLabelGroupId = null
if (!colorPickerOpen && !actionBarHovered && !editingSummary) {
hideTimeout = setTimeout(() => {
visibleGroup = undefined
}, 150)
}
}}
>
<div class="flex flex-row gap-2 h-fit">
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
title={isCollapsed(group.id) ? 'Expand group' : 'Collapse group'}
startIcon={{ icon: isCollapsed(group.id) ? ChevronRight : ChevronDown }}
onclick={() => toggleCollapse(group.id)}
/>
<NoteColorPicker
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
onColorChange={(color) => {
groupEditorContext?.groupEditor.updateColor(group.id, color)
{#if editingGroupId === group.id}
<input
class="text-xs font-medium bg-transparent border-none outline-none {getTextColorClass(group.color)} w-24"
bind:value={summaryInput}
onblur={() => commitSummary(group.id)}
onkeydown={(e) => {
if (e.key === 'Enter') commitSummary(group.id)
if (e.key === 'Escape') {
editingGroupId = null
}
}}
bind:isOpen={colorPickerOpen}
autofocus
/>
<Button
variant="subtle"
unifiedSize="xs"
title="Delete group"
startIcon={{ icon: X }}
onclick={() => {
groupEditorContext?.groupEditor.deleteGroup(group.id)
visibleGroup = undefined
}}
iconOnly
destructive
/>
</div>
{:else}
<span class="text-xs font-medium {getTextColorClass(group.color)}">
{group.summary || 'Group'}
</span>
{#if editMode && hoveredLabelGroupId === group.id}
<button
class="flex items-center justify-center w-4 h-4 rounded hover:bg-surface-hover {getTextColorClass(group.color)} opacity-60 hover:opacity-100"
onclick={() => startEditSummary(group)}
title="Edit group name"
>
<Pen size={10} />
</button>
{/if}
{/if}
</div>
{/if}
</div>
</ViewportPortal>
<!-- Action bar (top-right, hover only) — matches group note style -->
{#if editMode && visibleGroup?.id === group.id}
<div
class="absolute -top-7 right-0 p-1 h-7 group flex justify-end"
style="pointer-events: auto;"
onpointerenter={() => {
actionBarHovered = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = undefined
}
}}
onpointerleave={() => {
actionBarHovered = false
}}
>
<div class="flex flex-row gap-2 h-fit">
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
title="Collapse group"
startIcon={{ icon: ChevronDown }}
onclick={() => toggleCollapse(group.id)}
/>
<NoteColorPicker
selectedColor={(group.color as NoteColor) ?? NoteColor.BLUE}
onColorChange={(color) => {
groupEditorContext?.groupEditor.updateColor(group.id, color)
}}
bind:isOpen={colorPickerOpen}
/>
<Button
variant="subtle"
unifiedSize="xs"
title="Delete group"
startIcon={{ icon: X }}
onclick={() => {
groupEditorContext?.groupEditor.deleteGroup(group.id)
visibleGroup = undefined
}}
iconOnly
destructive
/>
</div>
</div>
{/if}
</div>
</ViewportPortal>
{/if}
{/if}
{/each}
@@ -62,6 +62,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
@@ -113,6 +114,7 @@ export type FlowNode =
| AssetsOverflowedN
| AiToolN
| NewAiToolN
| CollapsedGroupN
export type InputN = {
type: 'input2'
@@ -329,6 +331,19 @@ export type NewAiToolN = {
}
}
export type CollapsedGroupN = {
type: 'collapsedGroup'
data: {
offset: number
groupId: string
summary: string | undefined
description: string | undefined
color: string | undefined
stepCount: number
eventHandlers: GraphEventHandlers
}
}
export function topologicalSort(
nodes: { id: string; parentIds?: string[] }[]
): { id: string; parentIds?: string[] }[] {
@@ -396,7 +411,8 @@ export function graphBuilder(
selectedId: string | undefined,
simplifiableFlow: SimplifiableFlow | undefined,
flowPathForTriggerNode: string | undefined,
expandedSubflows: Record<string, FlowModule[]>
expandedSubflows: Record<string, FlowModule[]>,
collapsedGroups: Array<{ id: string; summary?: string; description?: string; color?: string; collapsed?: boolean; module_ids: string[] }>
// triggerProps?: {
// path?: string
// flowIsSimplifiable?: boolean
@@ -413,6 +429,15 @@ export function graphBuilder(
return { nodes: {}, edges: [] }
}
// Build a map: module_id -> collapsed group (only for collapsed groups)
const moduleToCollapsedGroup = new Map<string, (typeof collapsedGroups)[number]>()
for (const group of collapsedGroups) {
for (const moduleId of group.module_ids) {
moduleToCollapsedGroup.set(moduleId, group)
}
}
const emittedCollapsedGroups = new Set<string>()
const nodes: NodeLayout[] = []
const edges: Edge[] = []
@@ -634,6 +659,63 @@ export function graphBuilder(
}
} else {
modules.forEach((module, index) => {
// --- Collapsed group handling ---
const collapsedGroup = moduleToCollapsedGroup.get(module.id)
if (collapsedGroup) {
if (emittedCollapsedGroups.has(collapsedGroup.id)) {
// Already emitted — skip entirely, but wire final edge if last module
if (index === modules.length - 1 && previousId && nextNode) {
addEdge(previousId, nextNode.id, branch, prefix, {
subModules: modules,
disableMoveIds
})
}
return
}
// First member — emit placeholder node
emittedCollapsedGroups.add(collapsedGroup.id)
const nodeId = `collapsed-group:${collapsedGroup.id}`
nodes.push({
id: nodeId,
type: 'collapsedGroup',
data: {
offset: currentOffset,
groupId: collapsedGroup.id,
summary: collapsedGroup.summary,
description: collapsedGroup.description,
color: collapsedGroup.color,
stepCount: collapsedGroup.module_ids.length,
eventHandlers
}
} as NodeLayout)
// Edge from previous → group node
if (index === 0) {
addEdge(beforeNode.id, nodeId, undefined, prefix, {
subModules: modules,
disableMoveIds,
disableInsert: simplifiedTriggerView
})
} else if (previousId) {
addEdge(previousId, nodeId, branch, prefix, {
subModules: modules,
disableMoveIds
})
}
previousId = nodeId
// Final edge if last module
if (index === modules.length - 1 && nextNode) {
addEdge(nodeId, nextNode.id, branch, prefix, {
subModules: modules,
disableMoveIds
})
}
return
}
// --- End collapsed group handling ---
const localDisableMoveIds = [...disableMoveIds, module.id]
// Add the edge between the previous node and the current one
@@ -11,6 +11,7 @@ import { getContext, setContext } from 'svelte'
export type FlowGroup = {
id: string
summary?: string
description?: string
collapsed?: boolean
module_ids: Array<string>
color?: string
@@ -70,6 +71,7 @@ export class GroupEditor {
const newGroup: FlowGroup = {
id: generateId(),
description: '',
module_ids: filteredIds,
color
}
@@ -92,6 +94,11 @@ export class GroupEditor {
this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, summary } : g)))
}
updateDescription(groupId: string, description: string): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, description } : g)))
}
updateCollapsedDefault(groupId: string, collapsed: boolean): void {
const groups = this.getGroups()
this.setGroups(groups.map((g) => (g.id === groupId ? { ...g, collapsed } : g)))
@@ -0,0 +1,41 @@
<script lang="ts">
import { preventDefault, stopPropagation } from 'svelte/legacy'
import VirtualItem from '$lib/components/flows/map/VirtualItem.svelte'
import NodeWrapper from './NodeWrapper.svelte'
import { Maximize2 } from 'lucide-svelte'
import type { CollapsedGroupN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: CollapsedGroupN['data']
id: string
}
let { data, id }: Props = $props()
const { selectionManager } = getGraphContext()
</script>
<NodeWrapper offset={data.offset}>
{#snippet children({ darkMode })}
<VirtualItem
label={data.summary || 'Group'}
preLabel={`${data.stepCount} steps`}
selectable
selected={selectionManager && selectionManager.isNodeSelected(id)}
/>
<div class="z-50 absolute -top-4 right-11 rounded-md text-primary bg-surface">
<button
title="Expand group"
class="rounded-md center-center text-primary hover:bg-surface-tertiary shadow-md p-1 duration-0"
onclick={stopPropagation(
preventDefault(() => {
data.eventHandlers.expandGroup(data.groupId)
})
)}
>
<Maximize2 size={12} />
</button>
</div>
{/snippet}
</NodeWrapper>
+3
View File
@@ -224,6 +224,9 @@ components:
summary:
type: string
description: Display name for this group
description:
type: string
description: Markdown description shown when the group is collapsed
collapsed:
type: boolean
default: false