feat(flow-editor): add swimlanes and JSON IO type support

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 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-22 14:54:43 +02:00
parent 9f54896545
commit 0f155e2040
22 changed files with 763 additions and 16 deletions

View File

@ -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<string, unknown> | 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<string, unknown> | null;
};
export type FlowBlockConnection = {

View File

@ -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);
});
});
});

View File

@ -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<string, unknown>
: null
}));
}
function normalizeLanes(raw: unknown): FlowLane[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!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<FlowBlock | FlowContainer> {
if (!Array.isArray(raw)) return [];

View File

@ -85,7 +85,7 @@
<span>{{ output.name }} <small>{{ output.type }}{{ output.multiple ? ' · multiple' : '' }}</small></span>
@if (output.type === 'BOOLEAN' && !output.multiple) {
<input type="checkbox" [checked]="mockBooleanValue(output)" [disabled]="readonly" (change)="setBooleanMockOutput(output, $any($event.target).checked)" />
} @else if (output.multiple || output.type === 'ANY') {
} @else if (output.multiple || output.type === 'ANY' || output.type === 'JSON') {
<textarea [ngModel]="mockValueText(output)" [disabled]="readonly" rows="3" placeholder="Valid JSON value" (ngModelChange)="setJsonMockOutput(output, $event)"></textarea>
} @else {
<input type="text" [ngModel]="mockValueText(output)" [disabled]="readonly" (ngModelChange)="setTextMockOutput(output, $event)" />

View File

@ -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);

View File

@ -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 '';
}

View File

@ -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;

View File

@ -37,6 +37,12 @@
</button>
}
</div>
@if (laneBadge; as lane) {
<div class="container-node__lane-chip" [style.border-color]="lane.color || '#94a3b8'">
<i class="bi bi-diagram-3"></i>
<span>{{ lane.name }}</span>
</div>
}
</div>
<div class="container-node__header-actions">
@if (missingRequiredParams.length) {

View File

@ -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();

View File

@ -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<string, unknown> | 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<string, unknown> | undefined;
const value = nodeData?.[this.biasAnnotationsProperty];

View File

@ -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;

View File

@ -46,6 +46,12 @@
}
</div>
<div class="llm-node-id">{{ nodeIdLabel }}</div>
@if (laneBadge; as lane) {
<div class="llm-lane-chip" [style.border-color]="lane.color || '#94a3b8'">
<i class="bi bi-diagram-3"></i>
<span>{{ lane.name }}</span>
</div>
}
</div>
<div class="llm-header-actions">
@if (hasUpdateBlockError()) {

View File

@ -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({

View File

@ -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<string, unknown> | 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);
}

View File

@ -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;

View File

@ -6,6 +6,27 @@
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
@if (sortedLanes().length) {
<div class="rete-lanes-layer" aria-hidden="true">
<div class="rete-lanes-content" [style.transform]="laneContentTransform()">
@for (lane of sortedLanes(); track lane.id; let laneIndex = $index) {
<div
class="rete-lane-band"
[style.top.px]="laneBandTop(laneIndex)"
[style.height.px]="laneBandHeight()"
[style.left.px]="laneBandLeft"
[style.width.px]="laneBandWidth"
[style.background]="laneBandBackground(lane.color, laneIndex)"
[style.border-top-color]="lane.color || '#94a3b8'">
<div class="rete-lane-label" [style.left.px]="laneLabelLeft - laneBandLeft">
<span class="rete-lane-label-swatch" [style.background]="lane.color || '#94a3b8'"></span>
<span>{{ lane.name }}</span>
</div>
</div>
}
</div>
</div>
}
@if (!readonly()) {
<div class="rete-editor-toolbar">
<section class="rete-editor-toolbar-group" aria-label="Editor mode controls">
@ -48,7 +69,7 @@
</div>
}
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
<div class="h-full w-full rete-editor-canvas" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
</div>
@if (hasSelectedBlocks) {

View File

@ -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' });
}

View File

@ -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;
}

View File

@ -166,6 +166,7 @@
(ngModelChange)="updateGlobalInput($index, { type: $event })">
<option value="TEXT">TEXT</option>
<option value="FILE">FILE</option>
<option value="JSON">JSON</option>
</select>
</label>
@ -199,6 +200,78 @@
}
}
</div>
<div class="title-toolbar-lanes">
<div class="title-toolbar-lanes-header-row">
<button type="button" class="title-toolbar-global-inputs-toggle" (click)="toggleLanes()">
<mat-icon [fontIcon]="lanesOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
<span class="title-toolbar-global-inputs-title">Lanes</span>
<span class="title-toolbar-global-count">{{ lanes().length }}</span>
</button>
<div class="title-toolbar-global-inputs-actions">
@if (!readOnly()) {
<button
type="button"
class="title-toolbar-global-inputs-action"
matTooltip="Add lane"
aria-label="Add lane"
(click)="addLane()">
<mat-icon fontIcon="add"></mat-icon>
</button>
}
</div>
</div>
@if (lanesOpen()) {
@if (!lanes().length) {
<div class="title-toolbar-global-inputs-empty">No lanes defined. Nodes without a lane are shown as unassigned.</div>
} @else {
@for (lane of lanes(); track lane.id; let laneIndex = $index) {
<div class="title-toolbar-lane-row">
<span class="title-toolbar-lane-swatch" [style.background]="lane.color || '#94a3b8'"></span>
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input
matInput
[readonly]="readOnly()"
[ngModel]="lane.name"
(ngModelChange)="updateLane(laneIndex, { name: $event })"
placeholder="Hiring Manager" />
</mat-form-field>
<input
type="color"
class="title-toolbar-lane-color-input"
[disabled]="readOnly()"
[ngModel]="lane.color || '#94a3b8'"
(ngModelChange)="updateLane(laneIndex, { color: $event })" />
@if (!readOnly()) {
<div class="title-toolbar-lane-reorder">
<button type="button" mat-icon-button matTooltip="Move up" [disabled]="laneIndex === 0" (click)="moveLane(laneIndex, -1)">
<mat-icon fontIcon="arrow_upward"></mat-icon>
</button>
<button type="button" mat-icon-button matTooltip="Move down" [disabled]="laneIndex === lanes().length - 1" (click)="moveLane(laneIndex, 1)">
<mat-icon fontIcon="arrow_downward"></mat-icon>
</button>
</div>
<button
type="button"
mat-icon-button
class="title-toolbar-global-delete"
matTooltip="Delete lane"
(click)="removeLane(laneIndex)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
}
</div>
@if (laneValidationErrors()[laneIndex]; as laneError) {
<div class="title-toolbar-global-error">{{ laneError }}</div>
}
}
}
}
</div>
}
@if (creatingGlobalInput()) {
@ -222,6 +295,7 @@
(ngModelChange)="updateDraftGlobalInput({ type: $event })">
<option value="TEXT">TEXT</option>
<option value="FILE">FILE</option>
<option value="JSON">JSON</option>
</select>
</label>
@ -250,6 +324,45 @@
</div>
}
@if (creatingLane()) {
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewLane()"></div>
<div class="title-toolbar-global-modal" role="dialog" aria-modal="true" aria-labelledby="lane-modal-title">
<div class="title-toolbar-global-modal-title" id="lane-modal-title">Add Lane</div>
<mat-form-field appearance="outline" class="title-toolbar-global-modal-field">
<mat-label>Name</mat-label>
<input
matInput
[ngModel]="draftLane().name"
(ngModelChange)="updateDraftLane({ name: $event })"
placeholder="Hiring Manager" />
</mat-form-field>
<label class="title-toolbar-global-select title-toolbar-global-modal-select">
<span>Color</span>
<input
type="color"
[ngModel]="draftLane().color || '#94a3b8'"
(ngModelChange)="updateDraftLane({ color: $event })" />
</label>
@if (!draftLane().name.trim()) {
<div class="title-toolbar-global-error">Name is required</div>
} @else if (!canSaveDraftLane()) {
<div class="title-toolbar-global-error">Name must be unique</div>
}
<div class="title-toolbar-global-modal-help">
Lanes are presentation metadata: they group nodes by actor/responsibility and do not affect routing or execution.
</div>
<div class="title-toolbar-global-modal-actions">
<button type="button" mat-stroked-button (click)="cancelNewLane()">Cancel</button>
<button type="button" mat-flat-button [disabled]="!canSaveDraftLane()" (click)="saveNewLane()">Save</button>
</div>
</div>
}
@if (snackbarMessage()) {
<div class="save-snackbar" [class.save-snackbar-error]="snackbarType() === 'error'">
{{ snackbarMessage() }}

View File

@ -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<TitleToolbar>;
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]);
});
});
});

View File

@ -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<typeof setTimeout> | null = null;
readonly titleInputRef = viewChild<ElementRef>('titleInput');
@ -97,6 +98,26 @@ export class TitleToolbar {
creatingGlobalInput = signal(false);
draftGlobalInput = signal<FlowGlobalInput>({ name: '', type: 'TEXT', multiple: false });
lanesOpen = signal(false);
creatingLane = signal(false);
draftLane = signal<FlowLane>({ 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<string, number>();
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<FlowLane>) {
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<FlowLane>) {
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;
}

View File

@ -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<HFSchemes>) {
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<HFSchemes>) {
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<HFSchemes>, globalInput
runtime.globalInputs = cloneValue(globalInputs ?? []);
}
export function setEditorLanes(editor: NodeEditor<HFSchemes>, lanes: FlowLane[]) {
const runtime = editorRuntime.get(editor);
if (!runtime) return;
runtime.lanes = cloneValue(lanes ?? []);
}
export function getEditorLanes(editor: NodeEditor<HFSchemes>): FlowLane[] {
return editorRuntime.get(editor)?.lanes ?? [];
}
export function isProgrammaticNodeTranslation(area: AreaPlugin<HFSchemes, AreaExtra>, nodeId: string): boolean {
return areaProgrammaticTranslations.get(area)?.has(nodeId) === true;
}
export async function addBlockToEditor(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,