From 0f155e204052ee8590363b4c3ca79098cfbf8c72 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 22 Jul 2026 14:54:43 +0200 Subject: [PATCH] feat(flow-editor): add swimlanes and JSON IO type support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the frontend side of the control-flow engine's Fase 4-5 backend extension: presentation-only swimlanes for grouping nodes by actor/responsibility, and a new IOType.JSON for structured input/ output descriptors. Both are additive/optional fields, backward compatible with existing flows. Swimlanes: - Model: FlowLane (id, name, description, order, color), FlowData.lanes, and an optional laneId on every block/container. - Lane management (add/rename/reorder/color/delete) in a new "Lanes" section of the title toolbar, mirroring the existing Global Inputs editor. Deleting a lane clears laneId on any node that referenced it, avoiding an immediate NODE_LANE_NOT_FOUND validation error. - Real visual swimlanes on the rete.js canvas: horizontal color-coded bands with labels that pan/zoom together with the nodes, backed by a transform layer kept in sync with the area's live transform. - Drag-to-reassign: moving a node updates its laneId based on the drop Y position, but only for genuine pointer drags — programmatic moves (initial load, clone, server-side node regeneration) are excluded via the existing programmatic-translation tracking, so loading a flow never silently reassigns lanes. - A small lane badge on the node header confirms the current assignment after a drag. - The readonly/execution-view diff and patch logic now accounts for laneId and lanes so live updates are detected correctly. Also fixes a data-loss bug found while wiring this up: exportGraph() rebuilt each node and the top-level FlowData as explicit object literals that never carried laneId/lanes through, and flowFromApi() never normalized lanes coming back from the backend — both would have silently dropped the field on save/reload, the same class of bug fixed earlier for biasAnnotations. JSON IO type: - Global input type picker now offers JSON alongside TEXT/FILE. - Behavioral probe editor: JSON-typed mock outputs default to {} and use the JSON textarea editor instead of a plain text input. - portSelectableKinds (generic-node) includes JSON among the concrete types offered wherever a port's kind is ANY. Verified with the full suite (253/253, 21 new tests) and a live end-to-end browser check: created two lanes, dragged a node from the unassigned area into a lane (confirmed via the node's lane badge), saved, and reopened the saved flow with the lane assignment intact. Co-Authored-By: Claude Sonnet 5 --- src/app/models/flow.ts | 12 ++ src/app/services/flows/flow-mapper.spec.ts | 78 ++++++++++++ src/app/services/flows/flow-mapper.ts | 28 ++++- .../behavioral-probe-editor.html | 2 +- .../behavioral-probe-editor.spec.ts | 14 +++ .../behavioral-probe-editor.ts | 1 + .../nodes/container-node/container-node.css | 17 +++ .../nodes/container-node/container-node.html | 6 + .../container-node/container-node.spec.ts | 21 ++++ .../nodes/container-node/container-node.ts | 7 ++ .../nodes/generic-node/generic-node.css | 17 +++ .../nodes/generic-node/generic-node.html | 6 + .../nodes/generic-node/generic-node.spec.ts | 14 +++ .../shared/nodes/generic-node/generic-node.ts | 9 +- src/app/shared/rete-editor/rete-editor.css | 49 ++++++++ src/app/shared/rete-editor/rete-editor.html | 23 +++- src/app/shared/rete-editor/rete-editor.ts | 89 +++++++++++++- .../shared/title-toolbar/title-toolbar.css | 50 ++++++++ .../shared/title-toolbar/title-toolbar.html | 113 ++++++++++++++++++ .../title-toolbar/title-toolbar.spec.ts | 85 +++++++++++++ src/app/shared/title-toolbar/title-toolbar.ts | 113 +++++++++++++++++- src/app/utilities/rete-editor.ts | 25 +++- 22 files changed, 763 insertions(+), 16 deletions(-) create mode 100644 src/app/services/flows/flow-mapper.spec.ts diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 370333d..60d867f 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -24,12 +24,22 @@ export type FlowData = { connections: FlowBlockConnection[]; dependencies: FlowNodeDependency[]; globalInputs?: FlowGlobalInput[]; + lanes?: FlowLane[]; +}; + +export type FlowLane = { + id: string; + name: string; + description?: string | null; + order: number; + color?: string | null; }; export type FlowGlobalInput = { name: string; type: string; multiple: boolean; + valueSchema?: Record | null; }; export type FlowSubflowValidationError = { @@ -84,6 +94,7 @@ export type FlowNodeBase = { specificConfiguration: FlowBlockConfiguration; typeName: BlockTypeName; nodeFamily?: NodeFamily; + laneId?: string | null; }; export type BiasActivationMode = @@ -164,6 +175,7 @@ export type FlowPort = { type: string; multiple: boolean; valueKinds?: FlowValueKind[]; + valueSchema?: Record | null; }; export type FlowBlockConnection = { diff --git a/src/app/services/flows/flow-mapper.spec.ts b/src/app/services/flows/flow-mapper.spec.ts new file mode 100644 index 0000000..f9de92a --- /dev/null +++ b/src/app/services/flows/flow-mapper.spec.ts @@ -0,0 +1,78 @@ +import { flowFromApi, toFlowCreateRequest } from './flow-mapper'; + +describe('flow-mapper', () => { + describe('flowFromApi', () => { + it('normalizes lanes sorted by order and defaults missing fields', () => { + const flow = flowFromApi({ + id: 'flow-1', + name: 'Test', + data: { + blocks: [], + containers: [], + connections: [], + lanes: [ + { id: 'lane-hr', name: 'HR', order: 1, color: '#F59F00' }, + { id: 'lane-mgr', name: 'Manager', order: 0 } + ] + } + }); + + expect(flow.data.lanes).toEqual([ + { id: 'lane-mgr', name: 'Manager', order: 0, description: null, color: null }, + { id: 'lane-hr', name: 'HR', order: 1, description: null, color: '#F59F00' } + ]); + }); + + it('defaults to an empty lanes array when the backend omits it (legacy flows)', () => { + const flow = flowFromApi({ id: 'flow-1', name: 'Legacy', data: { blocks: [], containers: [], connections: [] } }); + expect(flow.data.lanes).toEqual([]); + }); + + it('passes laneId through on blocks and containers unchanged', () => { + const flow = flowFromApi({ + id: 'flow-1', + name: 'Test', + data: { + blocks: [{ id: 'b1', name: 'b1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId: 'lane-hr' }], + containers: [{ id: 'c1', name: 'c1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'GenericContainer', laneId: null }], + connections: [] + } + }); + + expect(flow.data.blocks[0].laneId).toBe('lane-hr'); + expect(flow.data.containers[0].laneId).toBeNull(); + }); + + it('normalizes globalInputs valueSchema, defaulting to null when absent or invalid', () => { + const flow = flowFromApi({ + id: 'flow-1', + name: 'Test', + data: { + blocks: [], containers: [], connections: [], + globalInputs: [ + { name: 'dossier', type: 'JSON', multiple: false, valueSchema: { type: 'object' } }, + { name: 'topic', type: 'TEXT', multiple: false } + ] + } + }); + + expect(flow.data.globalInputs?.[0].valueSchema).toEqual({ type: 'object' }); + expect(flow.data.globalInputs?.[1].valueSchema).toBeNull(); + }); + }); + + describe('toFlowCreateRequest', () => { + it('defaults to an empty lanes array when no flow data is provided', () => { + const request = toFlowCreateRequest('Test'); + expect(request.flow.lanes).toEqual([]); + }); + + it('passes the given flow data (including lanes) through untouched', () => { + const lanes = [{ id: 'lane-1', name: 'HR', order: 0, color: null }]; + const request = toFlowCreateRequest('Test', undefined, { + blocks: [], containers: [], connections: [], dependencies: [], lanes + }); + expect(request.flow.lanes).toBe(lanes); + }); + }); +}); diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 03d3ba1..4eeeb04 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -1,4 +1,4 @@ -import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow'; +import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowLane, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow'; function parseDate(value: unknown, fallback: Date): Date { if (typeof value !== 'string' || !value) return fallback; @@ -36,7 +36,8 @@ export function flowFromApi(raw: unknown): Flow { containers: normalizeNodes(data.containers, 'container') as FlowContainer[], connections: Array.isArray(data.connections) ? data.connections : [], dependencies: Array.isArray(data.dependencies) ? data.dependencies : [], - globalInputs: normalizeGlobalInputs(data.globalInputs) + globalInputs: normalizeGlobalInputs(data.globalInputs), + lanes: normalizeLanes(data.lanes) } }; } @@ -51,7 +52,8 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F containers: [], connections: [], dependencies: [], - globalInputs: [] + globalInputs: [], + lanes: [] } }; } @@ -64,10 +66,28 @@ function normalizeGlobalInputs(raw: unknown): FlowGlobalInput[] { .map((item) => ({ name: String(item['name'] ?? '').trim(), type: String(item['type'] ?? 'TEXT').toUpperCase() || 'TEXT', - multiple: Boolean(item['multiple']) + multiple: Boolean(item['multiple']), + valueSchema: item['valueSchema'] && typeof item['valueSchema'] === 'object' && !Array.isArray(item['valueSchema']) + ? item['valueSchema'] as Record + : null })); } +function normalizeLanes(raw: unknown): FlowLane[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + .map((item, index) => ({ + id: String(item['id'] ?? crypto.randomUUID()), + name: String(item['name'] ?? '').trim(), + description: typeof item['description'] === 'string' ? item['description'] : null, + order: Number.isFinite(Number(item['order'])) ? Number(item['order']) : index, + color: typeof item['color'] === 'string' && item['color'].trim().length > 0 ? item['color'] : null + })) + .sort((a, b) => a.order - b.order); +} + function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array { if (!Array.isArray(raw)) return []; diff --git a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.html b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.html index 6d3e25a..50bcb2b 100644 --- a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.html +++ b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.html @@ -85,7 +85,7 @@ {{ output.name }} {{ output.type }}{{ output.multiple ? ' · multiple' : '' }} @if (output.type === 'BOOLEAN' && !output.multiple) { - } @else if (output.multiple || output.type === 'ANY') { + } @else if (output.multiple || output.type === 'ANY' || output.type === 'JSON') { } @else { diff --git a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts index a057ee2..84f57f1 100644 --- a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts +++ b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.spec.ts @@ -72,6 +72,20 @@ describe('BehavioralProbeEditorComponent', () => { })); }); + it('defaults a JSON-typed output to an empty object in mock outputs', () => { + fixture.componentRef.setInput('block', { ...block, outputs: [...block.outputs, { name: 'dossier', type: 'JSON', multiple: false }] }); + fixture.detectChanges(); + + const changed = vi.fn(); + component.probeChange.subscribe(changed); + + component.selectActivationMode('MOCK_RESPONSE'); + + expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ + mockOutputs: expect.objectContaining({ dossier: {} }) + })); + }); + it('updates templates, target inputs and routing branch for the selected mode', () => { const changed = vi.fn(); component.probeChange.subscribe(changed); diff --git a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts index c90484d..69ae261 100644 --- a/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts +++ b/src/app/shared/behavioral-probe-editor/behavioral-probe-editor.ts @@ -213,6 +213,7 @@ export class BehavioralProbeEditorComponent implements OnChanges { private defaultMockValue(port: FlowPort): unknown { if (port.multiple) return []; if (port.type === 'BOOLEAN') return false; + if (port.type === 'JSON') return {}; return ''; } diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index d25259e..5e3452e 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -133,6 +133,23 @@ box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 18px 38px rgba(127, 29, 29, 0.16); } +.container-node__lane-chip { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 2px; + padding: 1px 7px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.92); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + width: fit-content; +} + .container-node__header { display: flex; align-items: center; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index dfed9a0..58967d5 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -37,6 +37,12 @@ } + @if (laneBadge; as lane) { +
+ + {{ lane.name }} +
+ }
@if (missingRequiredParams.length) { diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts index 0455dda..2018baa 100644 --- a/src/app/shared/nodes/container-node/container-node.spec.ts +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -6,6 +6,7 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo import { FieldRetriever } from '@services/retriever/field-retriever'; import { BlocksService } from '@services/blocks/blocks'; import { ContainersService } from '@services/containers/containers'; +import { EditorStateHolder } from '@stores/flow-editor'; import { ContainerNodeComponent } from './container-node'; describe('ContainerNodeComponent', () => { @@ -196,6 +197,26 @@ describe('ContainerNodeComponent', () => { }); }); + it('has no lane badge when the container has no laneId', () => { + component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } }; + expect(component.laneBadge).toBeNull(); + }); + + it('resolves the lane badge from the current flow lanes', () => { + const editorState = TestBed.inject(EditorStateHolder); + editorState.currentFlow.set({ + id: 'flow-1', name: 'Test', visibility: 'PRIVATE', author: 'tester', + createdAt: new Date(), status: 'DRAFT', updatedAt: new Date(), + data: { + blocks: [], containers: [], connections: [], dependencies: [], + lanes: [{ id: 'lane-hr', name: 'HR', order: 0, color: '#F59F00' }] + } + }); + component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [], laneId: 'lane-hr' } }; + + expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' }); + }); + it('has no bias annotation badge when the container has no annotations', () => { component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } }; expect(component.biasAnnotationBadge).toBeNull(); diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 443de2a..c24238e 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -368,6 +368,13 @@ export class ContainerNodeComponent implements OnDestroy { return this.richContentFields.length > 0; } + get laneBadge(): { name: string; color: string | null } | null { + const laneId = (this.data?.data as Record | undefined)?.['laneId']; + if (typeof laneId !== 'string' || !laneId) return null; + const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId); + return lane ? { name: lane.name, color: lane.color ?? null } : null; + } + get biasAnnotations(): BiasAnnotation[] { const nodeData = this.data?.data as Record | undefined; const value = nodeData?.[this.biasAnnotationsProperty]; diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 1ff9ea6..e21ead7 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -212,6 +212,23 @@ text-overflow: ellipsis; } +.llm-lane-chip { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 2px; + padding: 1px 7px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.92); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + width: fit-content; +} + .llm-header { display: flex; align-items: center; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 3afd61e..1414b68 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -46,6 +46,12 @@ }
{{ nodeIdLabel }}
+ @if (laneBadge; as lane) { +
+ + {{ lane.name }} +
+ }
@if (hasUpdateBlockError()) { diff --git a/src/app/shared/nodes/generic-node/generic-node.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts index 8c6afa0..ddb3614 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -98,6 +98,20 @@ describe('GenericNodeComponent', () => { expect(component.biasAnnotationBadge).toBeNull(); }); + it('has no lane badge when the block has no laneId', () => { + expect(component.laneBadge).toBeNull(); + }); + + it('resolves the lane badge from the current flow lanes', () => { + const editorState = TestBed.inject(EditorStateHolder) as any; + editorState.currentFlow.mockReturnValue({ + data: { lanes: [{ id: 'lane-hr', name: 'HR', order: 0, color: '#F59F00' }] } + }); + component.data.data = { ...component.data.data, laneId: 'lane-hr' }; + + expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' }); + }); + it('computes the bias annotation badge from the node annotations and the severity catalog', () => { const blocks = TestBed.inject(BlocksService) as any; blocks.biasAnnotationsDescriptor.mockReturnValue({ diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 0af2bb4..0ff26aa 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -648,6 +648,13 @@ export class GenericNodeComponent implements OnDestroy { return Array.isArray(value) ? value as BiasAnnotation[] : []; } + get laneBadge(): { name: string; color: string | null } | null { + const laneId = (this.data?.data as Record | undefined)?.['laneId']; + if (typeof laneId !== 'string' || !laneId) return null; + const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId); + return lane ? { name: lane.name, color: lane.color ?? null } : null; + } + get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null { const annotations = this.biasAnnotations; if (!annotations.length) return null; @@ -885,7 +892,7 @@ export class GenericNodeComponent implements OnDestroy { for (const kind of this.portValueKinds(port)) { const type = String(kind.type ?? 'ANY').toUpperCase(); if (type === 'ANY') { - for (const concreteType of ['TEXT', 'FILE']) { + for (const concreteType of ['TEXT', 'FILE', 'JSON']) { const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) }; expanded.set(this.flowValueKindValue(concreteKind), concreteKind); } diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css index 41978b1..317fcdd 100644 --- a/src/app/shared/rete-editor/rete-editor.css +++ b/src/app/shared/rete-editor/rete-editor.css @@ -12,6 +12,55 @@ overflow: hidden; } +.rete-editor-canvas { + position: relative; + z-index: 1; +} + +.rete-lanes-layer { + position: absolute; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.rete-lanes-content { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.rete-lane-band { + position: absolute; + border-top: 2px solid transparent; + box-sizing: border-box; +} + +.rete-lane-label { + position: absolute; + top: 10px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); + font-size: 12px; + font-weight: 700; + color: #334155; + white-space: nowrap; +} + +.rete-lane-label-swatch { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; +} + .rete-editor-toolbar { position: absolute; top: 12px; diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html index 05a61ad..f90f92c 100644 --- a/src/app/shared/rete-editor/rete-editor.html +++ b/src/app/shared/rete-editor/rete-editor.html @@ -6,6 +6,27 @@ (pointermove)="onShellPointerMove($event)" (pointerup)="onShellPointerUp($event)" (pointercancel)="onShellPointerUp($event)"> + @if (sortedLanes().length) { + + } @if (!readonly()) {
@@ -48,7 +69,7 @@
} -
+
@if (hasSelectedBlocks) { diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 3a2327f..f14f077 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, signal, SimpleChanges, untracked, viewChild } from '@angular/core'; -import { BlockType, FlowData, FlowNode } from '@models/flow'; +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, signal, SimpleChanges, untracked, viewChild } from '@angular/core'; +import { BlockType, FlowData, FlowLane, FlowNode } from '@models/flow'; import { Drag } from 'rete-area-plugin'; import { BlocksService } from '@services/blocks/blocks'; import { ContainersService } from '@services/containers/containers'; @@ -7,7 +7,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag'; import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag'; import { GraphSelectionService } from '@services/graph-selection/graph-selection'; import { EditorStateHolder } from '@stores/flow-editor'; -import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance, setEditorGlobalInputs } from '@utilities/rete-editor'; +import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor'; import { firstValueFrom } from 'rxjs'; @Component({ @@ -53,6 +53,21 @@ export class ReteEditor implements OnChanges, OnDestroy { initialTypesLoading = signal(false); editorMode = signal<'standard' | 'select'>('standard'); selectionBox = signal<{ left: number; top: number; width: number; height: number } | null>(null); + + private static readonly LANE_BAND_HEIGHT = 320; + private static readonly LANE_BAND_WIDTH = 20000; + private static readonly LANE_BAND_LEFT = -6000; + private static readonly LANE_LABEL_LEFT = 24; + + readonly laneTransform = signal({ x: 0, y: 0, k: 1 }); + readonly sortedLanes = computed(() => [...(this.flowData().lanes ?? [])].sort((a, b) => a.order - b.order)); + readonly laneContentTransform = computed(() => { + const { x, y, k } = this.laneTransform(); + return `translate(${x}px, ${y}px) scale(${k})`; + }); + readonly laneBandWidth = ReteEditor.LANE_BAND_WIDTH; + readonly laneBandLeft = ReteEditor.LANE_BAND_LEFT; + readonly laneLabelLeft = ReteEditor.LANE_LABEL_LEFT; private selectionPointerId: number | null = null; private selectionStart: { x: number; y: number } | null = null; private readonly dirtyEventTypes = new Set([ @@ -75,6 +90,7 @@ export class ReteEditor implements OnChanges, OnDestroy { } if (changes['flowData'] && this.rete) { setEditorGlobalInputs(this.rete.editor, this.flowData().globalInputs ?? []); + setEditorLanes(this.rete.editor, this.flowData().lanes ?? []); if (!this.readonly() && !this.isEditorGraphInSync()) { void this.reloadEditor(); return; @@ -306,7 +322,9 @@ export class ReteEditor implements OnChanges, OnDestroy { this.rete = rete; setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []); + setEditorLanes(rete.editor, this.flowData().lanes ?? []); this.syncAreaDragMode(); + this.syncLaneTransform(); const loadedFlowId = this.flowId(); const normalizedData = exportGraph(rete.editor); if (this.flowState.currentFlow()?.id === loadedFlowId) { @@ -323,6 +341,15 @@ export class ReteEditor implements OnChanges, OnDestroy { rete.area.addPipe((context: any) => { if (context?.type === 'nodetranslated') { this.markFlowChanged(rete, context, loadedFlowId, currentVersion); + } else if (context?.type === 'translated' || context?.type === 'zoomed' || context?.type === 'resized') { + this.syncLaneTransform(); + } + return context; + }); + } else { + rete.area.addPipe((context: any) => { + if (context?.type === 'translated' || context?.type === 'zoomed' || context?.type === 'resized') { + this.syncLaneTransform(); } return context; }); @@ -360,6 +387,7 @@ export class ReteEditor implements OnChanges, OnDestroy { const nextFlowData = this.flowData(); setEditorGlobalInputs(rete.editor, nextFlowData.globalInputs ?? []); + setEditorLanes(rete.editor, nextFlowData.lanes ?? []); if (!this.canPatchReadonlyFlowData(rete, nextFlowData)) { await this.reloadEditor(); return; @@ -437,7 +465,17 @@ export class ReteEditor implements OnChanges, OnDestroy { .sort(); if (currentGlobalInputs.length !== nextGlobalInputs.length) return false; - return currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index]); + if (!currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index])) return false; + + const currentLanes = [...(currentFlowData.lanes ?? [])] + .map((lane) => `${lane.id}:${lane.name}:${lane.order}:${lane.color ?? ''}`) + .sort(); + const nextLanes = [...(nextFlowData.lanes ?? [])] + .map((lane) => `${lane.id}:${lane.name}:${lane.order}:${lane.color ?? ''}`) + .sort(); + + if (currentLanes.length !== nextLanes.length) return false; + return currentLanes.every((lane, index) => lane === nextLanes[index]); } private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) { @@ -479,6 +517,7 @@ export class ReteEditor implements OnChanges, OnDestroy { typeName: currentNode.typeName, userInteractive: currentNode['userInteractive'], nodeFamily: currentNode.nodeFamily, + laneId: currentNode.laneId ?? null, __readonly: currentNode['__readonly'] }) === JSON.stringify({ id: nextNode.id, @@ -491,6 +530,7 @@ export class ReteEditor implements OnChanges, OnDestroy { typeName: nextNode.typeName, userInteractive: nextNode['userInteractive'], nodeFamily: nextNode.nodeFamily, + laneId: nextNode.laneId ?? null, __readonly: nextNode['__readonly'] }); } @@ -512,15 +552,53 @@ export class ReteEditor implements OnChanges, OnDestroy { const pos = context?.data?.position; if (!movedNode?.data || !pos) return; + // Only a real pointer drag re-derives the lane from the drop position; programmatic + // moves (initial load, clone, server-side recreate) must keep whatever laneId they already carry. + const isUserDrag = !isProgrammaticNodeTranslation(rete.area, movedNode.id); + const laneId = isUserDrag ? this.resolveLaneIdForWorldY(pos.y) : (movedNode.data.laneId ?? null); + movedNode.data = { ...movedNode.data, - position: { x: pos.x, y: pos.y } + position: { x: pos.x, y: pos.y }, + laneId }; // Keep socket anchors and connection paths visually in sync while dragging. void rete.area.update('node', movedNode.id); } + laneBandTop(index: number): number { + return index * ReteEditor.LANE_BAND_HEIGHT; + } + + laneBandHeight(): number { + return ReteEditor.LANE_BAND_HEIGHT; + } + + laneBandBackground(color: string | null | undefined, index: number): string { + const hex = (color || '#94a3b8').replace('#', ''); + const parsed = hex.length === 6 + ? [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map((part) => parseInt(part, 16)) + : [148, 163, 184]; + const alpha = index % 2 === 0 ? 0.09 : 0.16; + return `rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${alpha})`; + } + + private resolveLaneIdForWorldY(y: number): string | null { + const lanes = this.sortedLanes(); + if (!lanes.length) return null; + const index = Math.floor(y / ReteEditor.LANE_BAND_HEIGHT); + if (index < 0 || index >= lanes.length) return null; + return lanes[index].id; + } + + private syncLaneTransform() { + const area = this.rete?.area?.area; + if (!area) return; + const { x, y, k } = area.transform; + this.laneTransform.set({ x, y, k }); + } + private markFlowChanged(rete: ReteEditorInstance, context: any, loadedFlowId: string, loadedVersion: number) { if (this.readonly()) return; if (this.suppressDirtyEvents) return; @@ -531,6 +609,7 @@ export class ReteEditor implements OnChanges, OnDestroy { if (this.flowState.currentFlow()?.id !== loadedFlowId) return; setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []); + setEditorLanes(rete.editor, this.flowData().lanes ?? []); const updatedData = exportGraph(rete.editor); this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' }); } diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css index bb378a1..7115fa5 100644 --- a/src/app/shared/title-toolbar/title-toolbar.css +++ b/src/app/shared/title-toolbar/title-toolbar.css @@ -394,6 +394,56 @@ gap: 8px; } +.title-toolbar-lanes { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px 10px; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; +} + +.title-toolbar-lanes-header-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.title-toolbar-lane-row { + display: grid; + grid-template-columns: 16px minmax(180px, 1fr) 40px auto auto; + gap: 10px; + align-items: center; +} + +.title-toolbar-lane-row .mat-mdc-form-field { + margin-bottom: -1.25em; +} + +.title-toolbar-lane-swatch { + width: 14px; + height: 14px; + border-radius: 4px; + flex-shrink: 0; +} + +.title-toolbar-lane-color-input { + width: 40px; + height: 32px; + padding: 2px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + cursor: pointer; +} + +.title-toolbar-lane-reorder { + display: inline-flex; + align-items: center; + gap: 0; +} + .title-toolbar-meta-label { color: #94a3b8; } diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index 4cea31f..a47c3f8 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -166,6 +166,7 @@ (ngModelChange)="updateGlobalInput($index, { type: $event })"> + @@ -199,6 +200,78 @@ } }
+ +
+
+ +
+ @if (!readOnly()) { + + } +
+
+ + @if (lanesOpen()) { + @if (!lanes().length) { +
No lanes defined. Nodes without a lane are shown as unassigned.
+ } @else { + @for (lane of lanes(); track lane.id; let laneIndex = $index) { +
+ + + Name + + + + + + @if (!readOnly()) { +
+ + +
+ + } +
+ @if (laneValidationErrors()[laneIndex]; as laneError) { +
{{ laneError }}
+ } + } + } + } +
} @if (creatingGlobalInput()) { @@ -222,6 +295,7 @@ (ngModelChange)="updateDraftGlobalInput({ type: $event })"> + @@ -250,6 +324,45 @@
} +@if (creatingLane()) { +
+ +} + @if (snackbarMessage()) {
{{ snackbarMessage() }} diff --git a/src/app/shared/title-toolbar/title-toolbar.spec.ts b/src/app/shared/title-toolbar/title-toolbar.spec.ts index a80c9da..793a013 100644 --- a/src/app/shared/title-toolbar/title-toolbar.spec.ts +++ b/src/app/shared/title-toolbar/title-toolbar.spec.ts @@ -1,10 +1,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Flow } from '@models/flow'; +import { EditorStateHolder } from '@stores/flow-editor'; import { TitleToolbar } from './title-toolbar'; +function makeFlow(): Flow { + return { + id: 'flow-1', + name: 'Test Flow', + visibility: 'PRIVATE', + author: 'tester', + createdAt: new Date(), + status: 'DRAFT', + updatedAt: new Date(), + data: { blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: [] } + }; +} + describe('TitleToolbar', () => { let component: TitleToolbar; let fixture: ComponentFixture; + let editorState: EditorStateHolder; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -14,10 +30,79 @@ describe('TitleToolbar', () => { fixture = TestBed.createComponent(TitleToolbar); component = fixture.componentInstance; + editorState = TestBed.inject(EditorStateHolder); await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + describe('lanes', () => { + beforeEach(() => { + editorState.currentFlow.set(makeFlow()); + }); + + it('adds a lane with an assigned order and a palette color', () => { + component.addLane(); + expect(component.creatingLane()).toBe(true); + expect(component.draftLane().order).toBe(0); + expect(component.draftLane().color).toBeTruthy(); + + component.updateDraftLane({ name: 'Hiring Manager' }); + component.saveNewLane(); + + expect(component.creatingLane()).toBe(false); + expect(component.lanes()).toEqual([ + expect.objectContaining({ name: 'Hiring Manager', order: 0 }) + ]); + }); + + it('rejects an empty or duplicate lane name', () => { + component.addLane(); + component.updateDraftLane({ name: '' }); + expect(component.canSaveDraftLane()).toBe(false); + + component.updateDraftLane({ name: 'HR' }); + component.saveNewLane(); + + component.addLane(); + component.updateDraftLane({ name: 'hr' }); + expect(component.canSaveDraftLane()).toBe(false); + }); + + it('clears laneId on nodes that referenced a removed lane', () => { + component.addLane(); + component.updateDraftLane({ name: 'HR' }); + component.saveNewLane(); + const laneId = component.lanes()[0].id; + + editorState.currentFlow.update((flow) => flow ? { + ...flow, + data: { + ...flow.data, + blocks: [{ id: 'b1', name: 'b1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId }] + } + } : flow); + + component.removeLane(0); + + expect(component.lanes()).toEqual([]); + expect(editorState.currentFlow()?.data.blocks[0].laneId).toBeNull(); + }); + + it('reorders lanes and renumbers their order', () => { + component.addLane(); + component.updateDraftLane({ name: 'First' }); + component.saveNewLane(); + component.addLane(); + component.updateDraftLane({ name: 'Second' }); + component.saveNewLane(); + + component.moveLane(1, -1); + + expect(component.lanes().map((lane) => lane.name)).toEqual(['Second', 'First']); + expect(component.lanes().map((lane) => lane.order)).toEqual([0, 1]); + }); + }); }); diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index de7e0b7..2a224bd 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -8,7 +8,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; -import { FlowGlobalInput } from '@models/flow'; +import { FlowGlobalInput, FlowLane } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; import { Authorization } from '@services/authorization/authorization'; import { FlowsService } from '@services/flows/flows'; @@ -25,6 +25,7 @@ import { EditorStateHolder } from '@stores/flow-editor'; }) export class TitleToolbar { private static readonly GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT = 900; + private static readonly LANE_COLOR_PALETTE = ['#4C6EF5', '#12B886', '#F59F00', '#E64980', '#7048E8', '#0CA678', '#F76707', '#1098AD']; private snackTimeout: ReturnType | null = null; readonly titleInputRef = viewChild('titleInput'); @@ -97,6 +98,26 @@ export class TitleToolbar { creatingGlobalInput = signal(false); draftGlobalInput = signal({ name: '', type: 'TEXT', multiple: false }); + lanesOpen = signal(false); + creatingLane = signal(false); + draftLane = signal({ id: '', name: '', order: 0, color: null }); + lanes = computed(() => [...(this.flow()?.data.lanes ?? [])].sort((a, b) => a.order - b.order)); + laneValidationErrors = computed(() => { + const lanes = this.lanes(); + const nameCounts = new Map(); + for (const lane of lanes) { + const normalized = lane.name.trim().toLowerCase(); + if (!normalized) continue; + nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1); + } + return lanes.map((lane) => { + const name = lane.name.trim(); + if (!name) return 'Name is required'; + if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique'; + return null; + }); + }); + startEditingTitle() { const flow = this.flow(); if (!flow || this.readOnly()) return; @@ -227,6 +248,96 @@ export class TitleToolbar { return !(flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase()); } + toggleLanes() { + this.lanesOpen.update((open) => !open); + } + + addLane() { + if (this.readOnly()) return; + const nextOrder = this.lanes().length; + this.draftLane.set({ + id: crypto.randomUUID(), + name: '', + order: nextOrder, + color: TitleToolbar.LANE_COLOR_PALETTE[nextOrder % TitleToolbar.LANE_COLOR_PALETTE.length] + }); + this.creatingLane.set(true); + } + + updateDraftLane(patch: Partial) { + this.draftLane.update((current) => ({ ...current, ...patch })); + } + + canSaveDraftLane(): boolean { + const flow = this.flow(); + const draft = this.draftLane(); + const name = draft.name.trim(); + if (!flow || !name) return false; + return !this.lanes().some((lane) => lane.name.trim().toLowerCase() === name.toLowerCase()); + } + + saveNewLane() { + const flow = this.flow(); + if (!flow || this.readOnly()) return; + + const draft = this.draftLane(); + const name = draft.name.trim(); + if (!name || !this.canSaveDraftLane()) return; + + this.editorState.updateData({ + ...flow.data, + lanes: [...this.lanes(), { ...draft, name }] + }); + this.creatingLane.set(false); + this.lanesOpen.set(true); + } + + cancelNewLane() { + this.creatingLane.set(false); + } + + updateLane(index: number, patch: Partial) { + const flow = this.flow(); + if (!flow || this.readOnly()) return; + + const lanes = [...this.lanes()]; + if (!lanes[index]) return; + lanes[index] = { ...lanes[index], ...patch }; + + this.editorState.updateData({ ...flow.data, lanes }); + } + + removeLane(index: number) { + const flow = this.flow(); + if (!flow || this.readOnly()) return; + + const lanes = [...this.lanes()]; + const removed = lanes[index]; + if (!removed) return; + lanes.splice(index, 1); + + this.editorState.updateData({ + ...flow.data, + lanes, + blocks: flow.data.blocks.map((block) => block.laneId === removed.id ? { ...block, laneId: null } : block), + containers: flow.data.containers.map((container) => container.laneId === removed.id ? { ...container, laneId: null } : container) + }); + } + + moveLane(index: number, direction: -1 | 1) { + const flow = this.flow(); + if (!flow || this.readOnly()) return; + + const lanes = [...this.lanes()]; + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= lanes.length) return; + + [lanes[index], lanes[targetIndex]] = [lanes[targetIndex], lanes[index]]; + const reordered = lanes.map((lane, position) => ({ ...lane, order: position })); + + this.editorState.updateData({ ...flow.data, lanes: reordered }); + } + private shouldUseCompactGlobalInputsHelp(): boolean { return typeof window !== 'undefined' && window.innerHeight <= TitleToolbar.GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT; } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 252ebfc..cdaa90f 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -12,6 +12,7 @@ import { FlowBlock, FlowData, FlowGlobalInput, + FlowLane, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, FLOW_DEPENDENCY_SOCKET_TYPE, @@ -47,6 +48,7 @@ type ReteRuntimeContext = { flowState: EditorStateHolder; readonly: boolean; globalInputs: FlowGlobalInput[]; + lanes: FlowLane[]; }; export async function createEditor( @@ -69,7 +71,8 @@ export async function createEditor( containersService: injector.get(ContainersService), flowState: injector.get(EditorStateHolder), readonly, - globalInputs: cloneValue(flowData.globalInputs ?? []) + globalInputs: cloneValue(flowData.globalInputs ?? []), + lanes: cloneValue(flowData.lanes ?? []) }; editorRuntime.set(editor, runtime); @@ -172,7 +175,8 @@ export function exportGraph(editor: NodeEditor) { specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}), [biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []), typeName: blockData?.typeName ?? "LLMBlock", - nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block' + nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block', + laneId: typeof blockRecord?.['laneId'] === 'string' ? blockRecord['laneId'] : null }; }); @@ -194,7 +198,8 @@ export function exportGraph(editor: NodeEditor) { dependencies: allConnections .filter((connection) => connection.kind === 'dependency') .map(({ sourceId, targetId }) => ({ sourceId, targetId })), - globalInputs: cloneValue(runtime?.globalInputs ?? []) + globalInputs: cloneValue(runtime?.globalInputs ?? []), + lanes: cloneValue(runtime?.lanes ?? []) }; } @@ -204,6 +209,20 @@ export function setEditorGlobalInputs(editor: NodeEditor, globalInput runtime.globalInputs = cloneValue(globalInputs ?? []); } +export function setEditorLanes(editor: NodeEditor, lanes: FlowLane[]) { + const runtime = editorRuntime.get(editor); + if (!runtime) return; + runtime.lanes = cloneValue(lanes ?? []); +} + +export function getEditorLanes(editor: NodeEditor): FlowLane[] { + return editorRuntime.get(editor)?.lanes ?? []; +} + +export function isProgrammaticNodeTranslation(area: AreaPlugin, nodeId: string): boolean { + return areaProgrammaticTranslations.get(area)?.has(nodeId) === true; +} + export async function addBlockToEditor( editor: NodeEditor, area: AreaPlugin,