fix(flow-editor): render dynamic ports and park swimlanes

This commit is contained in:
Lucio Lelii 2026-07-22 17:39:03 +02:00
parent 0f155e2040
commit c031eb8535
12 changed files with 255 additions and 58 deletions

View File

@ -0,0 +1,5 @@
/**
* Keep persisted lane metadata intact while the swimlane UI is dormant.
* See docs/project-memory.md before changing this flag.
*/
export const SWIMLANES_ENABLED = false;

View File

@ -202,7 +202,7 @@ describe('ContainerNodeComponent', () => {
expect(component.laneBadge).toBeNull();
});
it('resolves the lane badge from the current flow lanes', () => {
it('does not render a lane badge while swimlanes are disabled', () => {
const editorState = TestBed.inject(EditorStateHolder);
editorState.currentFlow.set({
id: 'flow-1', name: 'Test', visibility: 'PRIVATE', author: 'tester',
@ -214,7 +214,7 @@ describe('ContainerNodeComponent', () => {
});
component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [], laneId: 'lane-hr' } };
expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' });
expect(component.laneBadge).toBeNull();
});
it('has no bias annotation badge when the container has no annotations', () => {

View File

@ -26,6 +26,7 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
import { firstValueFrom } from 'rxjs';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
import {
@ -369,6 +370,7 @@ export class ContainerNodeComponent implements OnDestroy {
}
get laneBadge(): { name: string; color: string | null } | null {
if (!SWIMLANES_ENABLED) return 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);

View File

@ -80,6 +80,44 @@ describe('GenericNodeComponent', () => {
expect(component).toBeTruthy();
});
it('renders every input and output after the Rete node payload is updated', () => {
fixture.componentRef.setInput('data', {
...component.data,
inputs: {
existing: { socket: { name: 'ANY' } },
new: { socket: { name: 'ANY' } }
},
outputs: {
noAssessment: { socket: { name: 'ANY' } },
excluded: { socket: { name: 'ANY' } },
continued: { socket: { name: 'ANY' } }
},
data: {
...component.data.data,
inputs: [
{ name: 'existing', type: 'ANY', multiple: false },
{ name: 'new', type: 'ANY', multiple: false }
],
outputs: [
{ name: 'noAssessment', type: 'ANY', multiple: false },
{ name: 'excluded', type: 'ANY', multiple: false },
{ name: 'continued', type: 'ANY', multiple: false }
]
}
});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const inputLabels = Array.from(host.querySelectorAll('.llm-row-input .llm-pill-name'))
.map((element) => element.textContent?.trim());
const outputLabels = Array.from(host.querySelectorAll('.llm-row-output .llm-pill-name'))
.map((element) => element.textContent?.trim());
expect(inputLabels).toEqual(['existing', 'new']);
expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']);
});
it('restores and syncs persisted expanded-mode state', () => {
const nodeData = component.data.data as Record<string, unknown>;
nodeData['__focusOpen'] = true;
@ -102,14 +140,14 @@ describe('GenericNodeComponent', () => {
expect(component.laneBadge).toBeNull();
});
it('resolves the lane badge from the current flow lanes', () => {
it('does not render a lane badge while swimlanes are disabled', () => {
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' });
expect(component.laneBadge).toBeNull();
});
it('computes the bias annotation badge from the node annotations and the severity catalog', () => {

View File

@ -17,6 +17,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { BlocksService } from '@services/blocks/blocks';
import { firstValueFrom, take } from 'rxjs';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import {
type UiConditionRule,
@ -160,10 +161,6 @@ export class GenericNodeComponent implements OnDestroy {
return this.isReadonly;
}
outputs: { key: string; socket: ClassicPreset.Socket }[] = [];
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
dependantOutput: RenderedSocketPort | null = null;
dependencyInput: RenderedSocketPort | null = null;
parameterFields: EditableFieldView[] = [];
parameterFieldGroups: EditableFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
@ -195,6 +192,38 @@ export class GenericNodeComponent implements OnDestroy {
return this.schemaLoading;
}
get outputs(): RenderedSocketPort[] {
return Object.entries(this.data?.outputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY)
.map(([key, output]) => ({
key,
socket: (output as any).socket as ClassicPreset.Socket
}));
}
get inputs(): RenderedSocketPort[] {
return Object.entries(this.data?.inputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY)
.map(([key, input]) => ({
key,
socket: (input as any).socket as ClassicPreset.Socket
}));
}
get dependantOutput(): RenderedSocketPort | null {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output
? { key: FLOW_DEPENDANT_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket }
: null;
}
get dependencyInput(): RenderedSocketPort | null {
const input = this.data?.inputs?.[FLOW_DEPENDENCY_PORT_KEY];
return input
? { key: FLOW_DEPENDENCY_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket }
: null;
}
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
@ -241,8 +270,6 @@ export class GenericNodeComponent implements OnDestroy {
if (this.data?.data) {
this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty;
}
this.outputs = [];
this.inputs = [];
this.parameterFields = [];
this.parameterFieldGroups = [];
this.richContentFields = [];
@ -250,24 +277,6 @@ export class GenericNodeComponent implements OnDestroy {
this.parameterDisplaySections = [];
this.arrayFields = [];
Object.entries(this.data.outputs).forEach(([key, output]) => {
const entry = { key, socket: (output as any).socket };
if (key === FLOW_DEPENDANT_PORT_KEY) {
this.dependantOutput = entry;
return;
}
this.outputs.push(entry);
});
Object.entries(this.data.inputs).forEach(([key, input]) => {
const entry = { key, socket: (input as any).socket };
if (key === FLOW_DEPENDENCY_PORT_KEY) {
this.dependencyInput = entry;
return;
}
this.inputs.push(entry);
});
const config = this.ensureBlockConfiguration();
this.name = toStringOrNull(config['name']) || this.name;
@ -649,6 +658,7 @@ export class GenericNodeComponent implements OnDestroy {
}
get laneBadge(): { name: string; color: string | null } | null {
if (!SWIMLANES_ENABLED) return 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);

View File

@ -35,30 +35,84 @@
.rete-lane-band {
position: absolute;
border-top: 2px solid transparent;
border-bottom: 1px solid rgba(100, 116, 139, 0.42);
box-sizing: border-box;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.rete-lane-label {
.rete-lane-headers {
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;
inset: 0 auto 0 0;
z-index: 3;
width: 190px;
overflow: hidden;
pointer-events: none;
background: linear-gradient(90deg, rgba(248, 250, 252, 0.82) 0%, rgba(248, 250, 252, 0.54) 72%, transparent 100%);
}
.rete-lane-header {
position: absolute;
left: 12px;
width: 158px;
min-height: 68px;
padding: 9px 11px 8px;
border: 1px solid rgba(148, 163, 184, 0.7);
border-left: 6px solid #94a3b8;
border-radius: 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.14);
color: #0f172a;
overflow: hidden;
}
.rete-lane-header-kicker {
color: #64748b;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.09em;
line-height: 1;
text-transform: uppercase;
}
.rete-lane-header-name {
margin-top: 4px;
font-size: 13px;
font-weight: 800;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rete-lane-label-swatch {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
.rete-lane-header-meta,
.rete-lane-header-description {
margin-top: 3px;
color: #64748b;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
}
.rete-lane-header-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rete-lane-header--compact {
min-height: 38px;
padding-top: 7px;
padding-bottom: 6px;
}
.rete-lane-header--compact .rete-lane-header-kicker,
.rete-lane-header--compact .rete-lane-header-meta,
.rete-lane-header--compact .rete-lane-header-description {
display: none;
}
.rete-lane-header--compact .rete-lane-header-name {
margin-top: 0;
}
.rete-editor-toolbar {
@ -73,6 +127,10 @@
pointer-events: none;
}
.rete-editor-toolbar--with-lanes {
left: 202px;
}
.rete-editor-toolbar-group {
position: relative;
display: inline-flex;

View File

@ -6,7 +6,7 @@
(pointermove)="onShellPointerMove($event)"
(pointerup)="onShellPointerUp($event)"
(pointercancel)="onShellPointerUp($event)">
@if (sortedLanes().length) {
@if (swimlanesEnabled && 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) {
@ -18,17 +18,31 @@
[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>
<div class="rete-lane-headers" aria-hidden="true">
@for (lane of sortedLanes(); track lane.id; let laneIndex = $index) {
<div
class="rete-lane-header"
[class.rete-lane-header--compact]="laneHeaderCompact()"
[style.top.px]="laneHeaderTop(laneIndex)"
[style.border-left-color]="lane.color || '#94a3b8'">
<div class="rete-lane-header-kicker">Swimlane {{ laneIndex + 1 }}</div>
<div class="rete-lane-header-name">{{ lane.name }}</div>
<div class="rete-lane-header-meta">
{{ laneNodeCount(lane.id) }} {{ laneNodeCount(lane.id) === 1 ? 'node' : 'nodes' }}
</div>
@if (lane.description) {
<div class="rete-lane-header-description">{{ lane.description }}</div>
}
</div>
}
</div>
}
@if (!readonly()) {
<div class="rete-editor-toolbar">
<div class="rete-editor-toolbar" [class.rete-editor-toolbar--with-lanes]="swimlanesEnabled && sortedLanes().length">
<section class="rete-editor-toolbar-group" aria-label="Editor mode controls">
<div class="rete-editor-toolbar-legend">Mode</div>
<button

View File

@ -63,4 +63,23 @@ describe('ReteEditor', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('does not render the dormant swimlane UI', () => {
fixture.componentRef.setInput('flowData', {
blocks: [
{ id: 'b1', name: 'Review', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId: 'hr' },
{ id: 'b2', name: 'Approve', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'HumanInteractionBlock', laneId: 'hr' }
],
containers: [],
connections: [],
dependencies: [],
lanes: [{ id: 'hr', name: 'HR', order: 0, color: '#0f766e' }]
});
component.laneTransform.set({ x: -900, y: 40, k: 0.75 });
fixture.detectChanges();
expect(component.swimlanesEnabled).toBe(false);
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lanes-layer')).toBeNull();
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lane-headers')).toBeNull();
});
});

View File

@ -9,6 +9,7 @@ import { GraphSelectionService } from '@services/graph-selection/graph-selection
import { EditorStateHolder } from '@stores/flow-editor';
import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor';
import { firstValueFrom } from 'rxjs';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
@Component({
selector: 'app-rete-editor',
@ -18,6 +19,7 @@ import { firstValueFrom } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReteEditor implements OnChanges, OnDestroy {
readonly swimlanesEnabled = SWIMLANES_ENABLED;
readonly flowData = input.required<FlowData>();
readonly flowId = input.required<string>();
readonly readonly = input<boolean>(false);
@ -57,7 +59,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
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));
@ -67,7 +68,15 @@ export class ReteEditor implements OnChanges, OnDestroy {
});
readonly laneBandWidth = ReteEditor.LANE_BAND_WIDTH;
readonly laneBandLeft = ReteEditor.LANE_BAND_LEFT;
readonly laneLabelLeft = ReteEditor.LANE_LABEL_LEFT;
readonly laneNodeCounts = computed(() => {
const counts = new Map<string, number>();
const nodes = [...(this.flowData().blocks ?? []), ...(this.flowData().containers ?? [])];
for (const node of nodes) {
if (!node.laneId) continue;
counts.set(node.laneId, (counts.get(node.laneId) ?? 0) + 1);
}
return counts;
});
private selectionPointerId: number | null = null;
private selectionStart: { x: number; y: number } | null = null;
private readonly dirtyEventTypes = new Set([
@ -555,7 +564,11 @@ export class ReteEditor implements OnChanges, OnDestroy {
// 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);
const laneId = !this.swimlanesEnabled
? (movedNode.data.laneId ?? null)
: isUserDrag
? this.resolveLaneIdForWorldY(pos.y)
: (movedNode.data.laneId ?? null);
movedNode.data = {
...movedNode.data,
@ -580,8 +593,29 @@ export class ReteEditor implements OnChanges, OnDestroy {
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})`;
const strongAlpha = index % 2 === 0 ? 0.16 : 0.22;
const softAlpha = index % 2 === 0 ? 0.07 : 0.11;
return `linear-gradient(90deg, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${strongAlpha}) 0%, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${softAlpha}) 30%, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${softAlpha}) 100%)`;
}
laneNodeCount(laneId: string): number {
return this.laneNodeCounts().get(laneId) ?? 0;
}
laneHeaderCompact(): boolean {
return this.laneTransform().k * ReteEditor.LANE_BAND_HEIGHT < 96;
}
laneHeaderTop(index: number): number {
const { y, k } = this.laneTransform();
const bandTop = y + this.laneBandTop(index) * k;
const bandHeight = ReteEditor.LANE_BAND_HEIGHT * k;
const compact = this.laneHeaderCompact();
const headerHeight = compact ? 38 : 68;
const padding = compact ? 5 : 10;
const naturalTop = bandTop + padding;
const lastTopInsideBand = bandTop + bandHeight - headerHeight - padding;
return Math.min(Math.max(12, naturalTop), lastTopInsideBand);
}
private resolveLaneIdForWorldY(y: number): string | null {

View File

@ -410,6 +410,16 @@
min-width: 0;
}
.title-toolbar-lanes-help {
padding: 8px 10px;
border-left: 3px solid #0f766e;
border-radius: 0 8px 8px 0;
background: #ecfdf5;
color: #315c58;
font-size: 12px;
line-height: 1.35;
}
.title-toolbar-lane-row {
display: grid;
grid-template-columns: 16px minmax(180px, 1fr) 40px auto auto;

View File

@ -201,11 +201,12 @@
}
</div>
@if (swimlanesEnabled) {
<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-inputs-title">Swimlanes</span>
<span class="title-toolbar-global-count">{{ lanes().length }}</span>
</button>
<div class="title-toolbar-global-inputs-actions">
@ -223,8 +224,11 @@
</div>
@if (lanesOpen()) {
<div class="title-toolbar-lanes-help">
Each horizontal band represents an owner or role. Drag a node vertically into a band to assign it.
</div>
@if (!lanes().length) {
<div class="title-toolbar-global-inputs-empty">No lanes defined. Nodes without a lane are shown as unassigned.</div>
<div class="title-toolbar-global-inputs-empty">No swimlanes defined. Nodes outside a band remain unassigned.</div>
} @else {
@for (lane of lanes(); track lane.id; let laneIndex = $index) {
<div class="title-toolbar-lane-row">
@ -273,6 +277,7 @@
}
</div>
}
}
@if (creatingGlobalInput()) {
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewGlobalInput()"></div>

View File

@ -15,6 +15,7 @@ import { FlowsService } from '@services/flows/flows';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { take } from 'rxjs';
import { EditorStateHolder } from '@stores/flow-editor';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
@Component({
selector: 'app-title-toolbar',
@ -24,6 +25,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TitleToolbar {
readonly swimlanesEnabled = SWIMLANES_ENABLED;
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;