feat: show and edit groups in the simplified view

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-27 03:13:52 +02:00
co-authored by Claude Opus 5.5
parent 13d9ec652e
commit da1c1fe690
7 changed files with 224 additions and 59 deletions
@@ -1338,6 +1338,7 @@
{selectionManager}
selectionOverlay={multiSelectEnabled ? selectionBox : undefined}
selectionKey={editMode ? modifierKey : undefined}
{showNotes}
structure={'structureTree' in graph ? graph.structureTree : undefined}
{editMode}
{insertable}
@@ -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]
)
</script>
<!-- A bracket over the group rather than a box around it: only the label takes clicks, so the
group's empty area stays part of the canvas. -->
<div class="relative pointer-events-none" style="width: {data.width}px; height: {data.height}px;">
{#if data.kind === 'group' && data.group}
<!-- A user-made group keeps the full graph's outlined, tinted box and its editable header -->
<div
class={twMerge(
'absolute inset-x-0 h-2.5 rounded-t-lg border-t border-x',
selected
? 'border-border-selected'
: (statusBracket ?? 'border-gray-300 dark:border-gray-600')
'relative rounded-lg outline outline-1 -outline-offset-1 pointer-events-none',
groupColors.outline,
groupColors.backgroundLight
)}
style="top: {headerTop + SIMPLIFIED.loopHeader + 1}px;"
></div>
<StepContextMenu items={menuItems}>
<div
class="absolute left-1 max-w-[calc(100%-8px)] inline-flex items-center gap-1.5 px-1.5 text-2xs text-secondary min-w-0 cursor-pointer pointer-events-auto"
style="top: {headerTop}px; height: {SIMPLIFIED.loopHeader}px;"
title={[header.title, header.detail].filter(Boolean).join(' · ')}
>
{#if header.module}
<span class="shrink-0"><FlowModuleIcon module={header.module} size={12} /></span>
{/if}
<span
class={twMerge(
'font-medium truncate shrink-0 max-w-[75%]',
selected ? 'text-accent' : 'text-emphasis'
)}
>
{header.title}
</span>
<span class="font-mono text-primary shrink-0">{header.stepId}</span>
{#if header.detail}
<span class="font-mono text-hint truncate min-w-0">{header.detail}</span>
{/if}
{#if canAddBranch}
<button
type="button"
title="Add branch"
class="nodrag shrink-0 rounded p-0.5 text-secondary hover:text-primary hover:bg-surface-hover"
onclick={(e) => {
e.stopPropagation()
edit?.eventHandlers?.newBranch(id)
}}
>
<GitBranchPlus size={12} />
</button>
{/if}
style="width: {data.width}px; height: {data.height}px;"
>
<div class="absolute inset-x-0 top-0 pointer-events-auto">
<GroupHeaderBlock
groupId={data.group.key}
summary={data.group.value.summary}
note={data.group.value.note}
color={data.group.value.color}
collapsed={false}
autocollapse={data.group.value.autocollapse ?? false}
editMode={!!edit?.editMode}
showNotes={edit?.showNotes ?? true}
/>
</div>
</StepContextMenu>
</div>
</div>
{:else}
<!-- A bracket over the group rather than a box around it: only the label takes clicks, so the
group's empty area stays part of the canvas. -->
<div class="relative pointer-events-none" style="width: {data.width}px; height: {data.height}px;">
<div
class={twMerge(
'absolute inset-x-0 h-2.5 rounded-t-lg border-t border-x',
selected
? 'border-border-selected'
: (statusBracket ?? 'border-gray-300 dark:border-gray-600')
)}
style="top: {headerTop + SIMPLIFIED.loopHeader + 1}px;"
></div>
<StepContextMenu items={menuItems}>
<div
class="absolute left-1 max-w-[calc(100%-8px)] inline-flex items-center gap-1.5 px-1.5 text-2xs text-secondary min-w-0 cursor-pointer pointer-events-auto"
style="top: {headerTop}px; height: {SIMPLIFIED.loopHeader}px;"
title={[header.title, header.detail].filter(Boolean).join(' · ')}
>
{#if header.module}
<span class="shrink-0"><FlowModuleIcon module={header.module} size={12} /></span>
{/if}
<span
class={twMerge(
'font-medium truncate shrink-0 max-w-[75%]',
selected ? 'text-accent' : 'text-emphasis'
)}
>
{header.title}
</span>
<span class="font-mono text-primary shrink-0">{header.stepId}</span>
{#if header.detail}
<span class="font-mono text-hint truncate min-w-0">{header.detail}</span>
{/if}
{#if canAddBranch}
<button
type="button"
title="Add branch"
class="nodrag shrink-0 rounded p-0.5 text-secondary hover:text-primary hover:bg-surface-hover"
onclick={(e) => {
e.stopPropagation()
edit?.eventHandlers?.newBranch(id)
}}
>
<GitBranchPlus size={12} />
</button>
{/if}
</div>
</StepContextMenu>
</div>
{/if}
@@ -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 `<module id>-branch-<n|default>`; here the lane
@@ -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}
</div>
</StepContextMenu>
{:else if data.kind === 'collapsedGroup' && data.group}
{@const groupColors =
NOTE_COLORS[(data.group.value.color as NoteColor) ?? NoteColor.BLUE] ??
NOTE_COLORS[NoteColor.BLUE]}
<div
class="rounded-lg outline outline-1 -outline-offset-1 {groupColors.outline} {groupColors.backgroundLight}"
style="width: {data.width}px; height: {data.height}px;"
>
<GroupHeaderBlock
groupId={data.group.key}
summary={data.group.value.summary}
note={data.group.value.note}
color={data.group.value.color}
collapsed={true}
autocollapse={data.group.value.autocollapse ?? false}
editMode={!!edit?.editMode}
showNotes={edit?.showNotes ?? true}
/>
{#if data.group.modules.length}
<div class="flex items-center justify-center w-full gap-1.5 px-2 h-[34px] overflow-hidden">
<GroupModuleIcons modules={data.group.modules} eventHandlers={edit?.eventHandlers} />
</div>
{/if}
</div>
{:else if data.kind === 'junction'}
<div
class="rounded-full bg-gray-400 dark:bg-gray-500"
@@ -14,6 +14,8 @@ export type SimplifiedEditContext = {
readonly eventHandlers: GraphEventHandlers | undefined
readonly editMode: boolean
readonly disableAi: boolean
/** Whether group notes are shown, as the full graph's Notes toggle sets it */
readonly showNotes: boolean
}
const KEY = 'SimplifiedFlowGraphEdit'
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { layoutSimplifiedFlow, SIMPLIFIED, type SimplifiedEdgeData } from './simplifiedLayout'
import {
layoutSimplifiedFlow,
SIMPLIFIED,
type SimplifiedEdgeData,
type SimplifiedNodeData
} from './simplifiedLayout'
const measure = (text: string) => 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 }
})
})
})
@@ -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<string, FlowModule>; editable: boolean }
type Ctx = {
measure: TextMeasurer
modules: Map<string, FlowModule>
editable: boolean
collapsedGroups: Set<string>
groupNoteHeights: Record<string, number>
}
/** 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<Box, 'id' | 'kind' | 'header' | 'group'>,
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<string>
/** Rendered height of each group's note, reserved under its header */
groupNoteHeights?: Record<string, number>
},
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,