From da1c1fe69036cc86a7e234ae000bc81b0a2e5dec Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 27 Sep 2026 03:13:52 +0200 Subject: [PATCH] feat: show and edit groups in the simplified view Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/components/graph/FlowGraphV2.svelte | 1 + .../graph/simplified/SimplifiedBox.svelte | 122 +++++++++++------- .../simplified/SimplifiedFlowGraph.svelte | 29 ++++- .../graph/simplified/SimplifiedNode.svelte | 27 ++++ .../graph/simplified/simplifiedContext.ts | 2 + .../graph/simplified/simplifiedLayout.test.ts | 42 +++++- .../graph/simplified/simplifiedLayout.ts | 60 +++++++-- 7 files changed, 224 insertions(+), 59 deletions(-) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index c9b2eae746..6c81795edf 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -1338,6 +1338,7 @@ {selectionManager} selectionOverlay={multiSelectEnabled ? selectionBox : undefined} selectionKey={editMode ? modifierKey : undefined} + {showNotes} structure={'structureTree' in graph ? graph.structureTree : undefined} {editMode} {insertable} diff --git a/frontend/src/lib/components/graph/simplified/SimplifiedBox.svelte b/frontend/src/lib/components/graph/simplified/SimplifiedBox.svelte index 334ee29263..24d641ee78 100644 --- a/frontend/src/lib/components/graph/simplified/SimplifiedBox.svelte +++ b/frontend/src/lib/components/graph/simplified/SimplifiedBox.svelte @@ -6,6 +6,8 @@ import { getSimplifiedEditContext, useStepMenu } from './simplifiedContext' import { getFlowRunStatusContext } from '../flowRunStatus.svelte' import { SIMPLIFIED, type SimplifiedBoxData } from './simplifiedLayout' + import GroupHeaderBlock from '../GroupHeaderBlock.svelte' + import { NOTE_COLORS, NoteColor } from '../noteColors' let { id, data, selected }: { id: string; data: SimplifiedBoxData; selected?: boolean } = $props() @@ -29,54 +31,84 @@ const stepMenu = useStepMenu() let menuItems = $derived(data.kind === 'group' ? [] : stepMenu(id, header.module)) let canAddBranch = $derived(data.kind === 'branch' && !!edit?.editMode && !!edit.eventHandlers) + + let groupColors = $derived( + NOTE_COLORS[(data.group?.value.color as NoteColor) ?? NoteColor.BLUE] ?? + NOTE_COLORS[NoteColor.BLUE] + ) - -
+{#if data.kind === 'group' && data.group} +
- -
- {#if header.module} - - {/if} - - {header.title} - - {header.stepId} - {#if header.detail} - {header.detail} - {/if} - {#if canAddBranch} - - {/if} + style="width: {data.width}px; height: {data.height}px;" + > +
+
- -
+
+{:else} + +
+
+ +
+ {#if header.module} + + {/if} + + {header.title} + + {header.stepId} + {#if header.detail} + {header.detail} + {/if} + {#if canAddBranch} + + {/if} +
+
+
+{/if} diff --git a/frontend/src/lib/components/graph/simplified/SimplifiedFlowGraph.svelte b/frontend/src/lib/components/graph/simplified/SimplifiedFlowGraph.svelte index 5a4552a6ae..576de3270c 100644 --- a/frontend/src/lib/components/graph/simplified/SimplifiedFlowGraph.svelte +++ b/frontend/src/lib/components/graph/simplified/SimplifiedFlowGraph.svelte @@ -15,6 +15,7 @@ import type { GraphEventHandlers } from '../graphBuilder.svelte' import type { SelectionManager } from '../selectionUtils.svelte' import SelectionTool from '../SelectionTool.svelte' + import { getGraphContext } from '../graphContext' import SimplifiedNode from './SimplifiedNode.svelte' import SimplifiedBox from './SimplifiedBox.svelte' import SimplifiedEdge from './SimplifiedEdge.svelte' @@ -46,6 +47,8 @@ selectionOverlay?: Snippet<[Node[]]> /** Held while dragging on the canvas to select a rectangle of steps */ selectionKey?: string + /** Whether group notes are shown */ + showNotes?: boolean } let { @@ -64,7 +67,8 @@ disableAi = false, selectionManager, selectionOverlay, - selectionKey + selectionKey, + showNotes = true }: Props = $props() setSimplifiedEditContext({ @@ -76,16 +80,37 @@ }, get disableAi() { return disableAi + }, + get showNotes() { + return showNotes } }) + // Collapse and note heights live on the full graph's display state, so both views share them + const groupDisplayState = getGraphContext()?.groupDisplayState + let collapsedGroups = $derived( + new Set( + (structure ?? []) + .flatMap(function keys(n): string[] { + return [ + ...(n.kind === 'group' ? [n.id] : []), + ...n.branches.flatMap((b) => b.children.flatMap(keys)) + ] + }) + .filter((key) => groupDisplayState?.isRuntimeCollapsed(key)) + ) + ) + let groupNoteHeights = $derived(showNotes ? (groupDisplayState?.getNoteHeights() ?? {}) : {}) + let layout = $derived( layoutSimplifiedFlow({ modules: modules ?? [], structure, failureModule, preprocessorModule, - editable: editMode && insertable && !!eventHandlers + editable: editMode && insertable && !!eventHandlers, + collapsedGroups, + groupNoteHeights }) ) // The full graph selects a branch lane as `-branch-`; here the lane diff --git a/frontend/src/lib/components/graph/simplified/SimplifiedNode.svelte b/frontend/src/lib/components/graph/simplified/SimplifiedNode.svelte index 5e97e899e2..f00d067032 100644 --- a/frontend/src/lib/components/graph/simplified/SimplifiedNode.svelte +++ b/frontend/src/lib/components/graph/simplified/SimplifiedNode.svelte @@ -10,6 +10,9 @@ import { SIMPLIFIED, type SimplifiedNodeData } from './simplifiedLayout' import { getSimplifiedEditContext, useStepMenu } from './simplifiedContext' import SimplifiedSlot from './SimplifiedSlot.svelte' + import GroupHeaderBlock from '../GroupHeaderBlock.svelte' + import GroupModuleIcons from '../GroupModuleIcons.svelte' + import { NOTE_COLORS, NoteColor } from '../noteColors' let { id, data, selected }: { id: string; data: SimplifiedNodeData; selected?: boolean } = $props() @@ -87,6 +90,30 @@ {/if} +{:else if data.kind === 'collapsedGroup' && data.group} + {@const groupColors = + NOTE_COLORS[(data.group.value.color as NoteColor) ?? NoteColor.BLUE] ?? + NOTE_COLORS[NoteColor.BLUE]} +
+ + {#if data.group.modules.length} +
+ +
+ {/if} +
{:else if data.kind === 'junction'}
text.length * 7 @@ -107,4 +112,39 @@ describe('layoutSimplifiedFlow', () => { expect(junction.position.x).toBeGreaterThan(byId.a.position.x + byId.a.width!) expect(junction.position.x + junction.width!).toBeLessThan(byId.c.position.x) }) + + it('gives every "+" the insert payload the editor resolves, branch-one default being branch 0', () => { + const modules: FlowModule[] = [ + step('a', 'A'), + { + id: 'b', + value: { + type: 'branchone', + branches: [{ expr: 'x', modules: [step('c', 'C')] }], + default: [] + } + } + ] + const { nodes } = layoutSimplifiedFlow({ modules, editable: true }, measure) + const inserts = nodes.flatMap((n) => { + const { insert } = n.data as SimplifiedNodeData + return insert ? [{ id: n.id, ...insert }] : [] + }) + const at = (index: number, rootId?: string, branch?: number) => + inserts.find( + (i) => i.index === index && i.branch?.rootId === rootId && i.branch?.branch === branch + ) + + // Top level: between Input and a (the only place a trigger may go), and before Result + expect(at(0)).toMatchObject({ sourceId: 'Input', targetId: 'a', allowTrigger: true }) + expect(at(2)).toMatchObject({ sourceId: 'b', targetId: 'Result', allowTrigger: false }) + // Inside the condition's lane, which the step tree numbers after the default + expect(at(0, 'b', 1)).toMatchObject({ targetId: 'c', ancestors: ['b'] }) + expect(at(1, 'b', 1)).toMatchObject({ sourceId: 'c' }) + // The empty default lane's dot is its own "+" + expect(inserts.find((i) => i.id === 'b:empty-0')).toMatchObject({ + index: 0, + branch: { rootId: 'b', branch: 0 } + }) + }) }) diff --git a/frontend/src/lib/components/graph/simplified/simplifiedLayout.ts b/frontend/src/lib/components/graph/simplified/simplifiedLayout.ts index bf5efdb719..a53ab9e618 100644 --- a/frontend/src/lib/components/graph/simplified/simplifiedLayout.ts +++ b/frontend/src/lib/components/graph/simplified/simplifiedLayout.ts @@ -3,7 +3,7 @@ import type { FlowModule } from '$lib/gen' import { prettyLanguage } from '$lib/common' import { getAllModules } from '$lib/components/flows/flowExplorer' import { buildStructureTree, type FlowStructureNode } from '../flowStructure' -import type { FlowGroup } from '../groupEditor.svelte' +import { GROUP_HEADER_HEIGHT, type FlowGroup } from '../groupEditor.svelte' /** * Left-to-right layout for the simplified flow view. Each layer is a column whose width is its @@ -28,6 +28,7 @@ export const SIMPLIFIED = { trunkGap: 88, /** Column gap holding a junction, with a trunk on each side of it */ junctionGap: 120, + collapsedGroupWidth: 240, /** Extra box side padding and column gap in the editor, where "+" buttons sit there */ editRing: 12, editGap: 32 @@ -46,6 +47,8 @@ export type SimplifiedNodeKind = | 'caption' /** A "+" between two steps, only laid out when the graph is editable */ | 'slot' + /** A collapsed group, drawn as one card in place of its steps */ + | 'collapsedGroup' export type SimplifiedNodeData = { kind: SimplifiedNodeKind @@ -61,6 +64,8 @@ export type SimplifiedNodeData = { insert?: InsertTarget /** On a branch caption, when that branch can be deleted */ deleteBranch?: { id: string; index: number } + /** On a collapsed group: the group, keyed by `groupKey`, and the steps it hides */ + group?: { key: string; value: FlowGroup; modules: FlowModule[] } } /** @@ -86,8 +91,10 @@ export type SimplifiedBoxData = { width: number height: number header: SimplifiedNodeData - /** Only for groups */ - group?: FlowGroup + /** Height reserved above the contents for the header (and a group's note) */ + headerHeight: number + /** Only for groups, keyed by `groupKey` */ + group?: { key: string; value: FlowGroup } } export type SimplifiedEdgeData = { @@ -272,7 +279,8 @@ type Box = { id: string kind: SimplifiedBoxKind header: SimplifiedNodeData - group?: FlowGroup + headerHeight: number + group?: { key: string; value: FlowGroup } startLayer: number endLayer: number y: number @@ -307,7 +315,13 @@ type Block = { endLayer: number } -type Ctx = { measure: TextMeasurer; modules: Map; editable: boolean } +type Ctx = { + measure: TextMeasurer + modules: Map + editable: boolean + collapsedGroups: Set + groupNoteHeights: Record +} /** Where a sequence of steps lives in the step tree, as the insert payload names it */ type Container = { rootRef?: string; branch: number; ancestors: string[] } @@ -524,10 +538,11 @@ function layoutLane( function boxed( box: Pick, layer: number, - inner: Block + inner: Block, + headerHeight: number = SIMPLIFIED.loopHeader ): Block { const pad = SIMPLIFIED.loopPad - const top = pad + SIMPLIFIED.loopHeader + const top = pad + headerHeight const height = top + inner.height + pad const b = shift(inner, top) return { @@ -536,6 +551,7 @@ function boxed( boxes: [ { ...box, + headerHeight, startLayer: layer, endLayer: inner.endLayer, y: 0, @@ -583,9 +599,23 @@ function groupHeader(group: FlowGroup | undefined, stepCount: number): Simplifie } function layoutNode(node: FlowStructureNode, layer: number, ancestors: string[], ctx: Ctx): Block { - if (node.kind === 'group') { + if (node.kind === 'group' && node.group) { + const group = { key: node.id, value: node.group } + const noteHeight = ctx.groupNoteHeights[node.id] ?? 0 + if (ctx.collapsedGroups.has(node.id)) { + const modules = (node.moduleIds ?? []).flatMap((id) => ctx.modules.get(id) ?? []) + const data: SimplifiedNodeData = { + kind: 'collapsedGroup', + title: node.group.summary || 'Group', + group: { ...group, modules }, + width: SIMPLIFIED.collapsedGroupWidth, + // Header, then a row of the hidden steps' icons, as the full graph's collapsed card + height: GROUP_HEADER_HEIGHT + 34 + 8 + noteHeight + } + return leaf(`collapsed-group:${node.id}`, data, layer) + } const rootRef = ref(node) - const container = { rootRef, branch: 0, ancestors } + const container = { rootRef, branch: 0, ancestors: [...ancestors, rootRef] } const lane = layoutLane( node.branches[0]?.children ?? [], layer, @@ -595,7 +625,8 @@ function layoutNode(node: FlowStructureNode, layer: number, ancestors: string[], ctx ) const header = groupHeader(node.group, node.moduleIds?.length ?? 0) - return boxed({ id: rootRef, kind: 'group', header, group: node.group }, layer, lane) + const headerHeight = GROUP_HEADER_HEIGHT + 6 + noteHeight + return boxed({ id: rootRef, kind: 'group', header, group }, layer, lane, headerHeight) } const mod = ctx.modules.get(node.id) @@ -684,13 +715,19 @@ export function layoutSimplifiedFlow( preprocessorModule?: FlowModule /** Lays out the "+" slots, and a lane for empty loop bodies to insert into */ editable?: boolean + /** Keys of the groups currently collapsed, each drawn as one card */ + collapsedGroups?: Set + /** Rendered height of each group's note, reserved under its header */ + groupNoteHeights?: Record }, measure: TextMeasurer = measureTextWidth ): SimplifiedLayout { const ctx: Ctx = { measure, modules: new Map(getAllModules(opts.modules ?? []).map((m) => [m.id, m])), - editable: opts.editable ?? false + editable: opts.editable ?? false, + collapsedGroups: opts.collapsedGroups ?? new Set(), + groupNoteHeights: opts.groupNoteHeights ?? {} } const structure = opts.structure ?? buildStructureTree(opts.modules ?? [], []) @@ -828,6 +865,7 @@ export function layoutSimplifiedFlow( boxBounds.set(b.id, { left, right }) const data: SimplifiedBoxData = { kind: b.kind, + headerHeight: b.headerHeight, width: right - left, height: b.height, header: b.header,