From bbbae3632acd551b2158b2c3f8575c69d62f8e26 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 29 Sep 2026 21:08:42 +0200 Subject: [PATCH 01/14] feat(storage): refresh operation schema from resource links --- src/app/shared/nodes/generic-node/generic-node.ts | 3 +++ src/app/utilities/rete-editor.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 3028877..13d0b7d 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -1040,6 +1040,9 @@ export class GenericNodeComponent implements OnDestroy { } private isStructuralField(path: string): boolean { + // Storage operation settings are supplied by a dynamic schema. Their nested properties are not + // present in the static root schema, but a SQL statement changes placeholder input ports. + if (this.blockType === 'StorageOperation' && path.startsWith('settings.')) return true; return getSchemaPathUiMeta(this.blockSchema, path).structural; } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 250b962..e2dd2c8 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -862,13 +862,17 @@ async function syncResourceLinkPresentation( candidate.target === target.id && candidate.targetInput === FLOW_RESOURCE_LINK_PORT_KEY); const source = activeLink ? editor.getNode(activeLink.source) as HFNode | undefined : undefined; const resourceId = typeof source?.data?.id === 'string' ? source.data.id : null; + const resourceType = typeof source?.data?.typeName === 'string' ? source.data.typeName : null; const targetData = target.data as Record; const configuration = targetData['specificConfiguration'] as Record | undefined; - if (configuration?.[reference.field] === resourceId) return; + const resourceChanged = configuration?.[reference.field] !== resourceId; + if (!resourceChanged && configuration?.['resourceType'] === resourceType) return; targetData['specificConfiguration'] = { ...(configuration ?? {}), - [reference.field]: resourceId + [reference.field]: resourceId, + ...(targetData['typeName'] === 'StorageOperation' ? { resourceType } : {}), + ...(resourceChanged && targetData['typeName'] === 'StorageOperation' ? { operationId: null, settings: {} } : {}) }; await area.update('node', target.id); } From 95d33d218be2e096912723cc6c76d1e60b171c46 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 29 Sep 2026 21:10:40 +0200 Subject: [PATCH 02/14] style(resource): simplify card border --- src/app/shared/nodes/resource-node/resource-node.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/shared/nodes/resource-node/resource-node.css b/src/app/shared/nodes/resource-node/resource-node.css index b06452b..99360a7 100644 --- a/src/app/shared/nodes/resource-node/resource-node.css +++ b/src/app/shared/nodes/resource-node/resource-node.css @@ -18,7 +18,6 @@ box-sizing: border-box; padding: 12px 16px 12px 12px; border: 1.5px solid #99f6e4; - border-left: 5px solid #0d9488; border-radius: 14px; background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%); box-shadow: 0 1px 2px rgba(15, 118, 110, 0.08), 0 6px 16px -10px rgba(15, 118, 110, 0.35); @@ -79,7 +78,6 @@ .resource-card--error { border-color: #fca5a5; - border-left-color: #dc2626; } .resource-card--deleting { From d4ab8f8fb18ae3916c214d81878fbadb0b1a6639 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 29 Sep 2026 21:18:37 +0200 Subject: [PATCH 03/14] fix(nodes): preserve open settings after replacement --- .../shared/nodes/generic-node/generic-node.spec.ts | 11 +++++++++++ src/app/shared/nodes/generic-node/generic-node.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+) 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 22a1966..3f947e2 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -109,6 +109,17 @@ describe('GenericNodeComponent', () => { expect(fixture.nativeElement.querySelector('.llm-modal-backdrop')).not.toBeNull(); }); + it('restores open settings after a structural edit replaces the node', () => { + component.toggleNodeSettings(); + expect(component.data.data.__nodeSettingsOpen).toBe(true); + + // Replacement creates a new Angular component, whose local state starts closed. + component.nodeSettingsOpen = false; + (component as any).restorePersistedNodeSettingsState(); + + expect(component.nodeSettingsOpen).toBe(true); + }); + describe('clearing an optional number', () => { /** Opens the modal editor on a numeric path, as clicking the pen does. */ function openNumericEditor(path: string, current: unknown) { diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 13d0b7d..acb53a0 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -337,6 +337,7 @@ export class GenericNodeComponent implements OnDestroy { this.refreshValidationState(); this.refreshParameterFields(); + this.restorePersistedNodeSettingsState(); this.restorePersistedFocusState(); void this.loadSchemaContext(); } @@ -374,6 +375,7 @@ export class GenericNodeComponent implements OnDestroy { event?.preventDefault(); event?.stopPropagation(); this.nodeSettingsOpen = !this.nodeSettingsOpen; + this.syncPersistedNodeSettingsState(); this.cdr.markForCheck(); } @@ -839,6 +841,18 @@ export class GenericNodeComponent implements OnDestroy { ?? (typeof mostSevereValue === 'string' ? mostSevereValue : null); } + /** A structural edit replaces the Rete node, so keep this UI-only state with its data. */ + private restorePersistedNodeSettingsState() { + const nodeData = this.data?.data as Record | undefined; + this.nodeSettingsOpen = nodeData?.['__nodeSettingsOpen'] === true; + } + + private syncPersistedNodeSettingsState() { + const nodeData = this.data?.data as Record | undefined; + if (!nodeData) return; + nodeData['__nodeSettingsOpen'] = this.nodeSettingsOpen; + } + private ensureBlockConfiguration(): Record { if (!this.data?.data) { this.data.data = {}; From 421ef2351894d10ceda22f38129ffa158dca440c Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 29 Sep 2026 21:40:15 +0200 Subject: [PATCH 04/14] fix(storage): refresh operations after resource change --- src/app/utilities/rete-editor.spec.ts | 41 ++++++++++++++++++++++++++- src/app/utilities/rete-editor.ts | 22 +++++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index a98084b..97abb85 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/f import { HFNode } from '@models/nodes'; import { vi } from 'vitest'; -import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor'; +import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor'; function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode { return { data: { typeName, nodeFamily } } as unknown as HFNode; @@ -88,6 +88,45 @@ describe('isResourceNode', () => { }); describe('the link between a resource and a step that uses it', () => { + it('replaces an S3 operation choice with PostgreSQL choices when its resource changes', async () => { + const { NodeEditor, ClassicPreset } = await import('rete'); + const { FLOW_RESOURCE_LINK_PORT_KEY, FLOW_RESOURCE_PORT_KEY } = await import('@models/flow'); + const editor = new NodeEditor(); + const socket = new ClassicPreset.Socket('__FLOW_RESOURCE__'); + const s3 = new ClassicPreset.Node('S3') as any; + s3.data = { id: 's3', typeName: 'S3Storage', nodeFamily: 'resource' }; + s3.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket)); + const postgres = new ClassicPreset.Node('PostgreSQL') as any; + postgres.data = { id: 'postgres', typeName: 'PostgresStorage', nodeFamily: 'resource' }; + postgres.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket)); + const operation = new ClassicPreset.Node('Operation') as any; + operation.data = { + id: 'operation', typeName: 'StorageOperation', nodeFamily: 'block', __resourceRefField: 'storageNode', + specificConfiguration: { storageNode: 's3', resourceType: 'S3Storage', operationId: 'GET_OBJECT', settings: { key: 'a' } } + }; + operation.addInput(FLOW_RESOURCE_LINK_PORT_KEY, new ClassicPreset.Input(socket, undefined, true)); + await editor.addNode(s3); + await editor.addNode(postgres); + await editor.addNode(operation); + const runtime = makeRuntime({ + blocksService: { peekBlockType: vi.fn().mockReturnValue(null) } as any + }); + const area = { update: vi.fn().mockResolvedValue(undefined) } as any; + + const oldLink = new ClassicPreset.Connection(s3, FLOW_RESOURCE_PORT_KEY, operation, FLOW_RESOURCE_LINK_PORT_KEY); + await editor.addConnection(oldLink); + await editor.removeConnection(oldLink.id); + await syncResourceLinkPresentation(editor, area, runtime, oldLink as any); + const newLink = new ClassicPreset.Connection(postgres, FLOW_RESOURCE_PORT_KEY, operation, FLOW_RESOURCE_LINK_PORT_KEY); + await editor.addConnection(newLink); + await syncResourceLinkPresentation(editor, area, runtime, newLink as any); + + expect(operation.data.specificConfiguration).toMatchObject({ + storageNode: 'postgres', resourceType: 'PostgresStorage', operationId: null, settings: {} + }); + expect(area.update).toHaveBeenCalledWith('node', operation.id); + }); + it('is what fills the step\'s resource field, and empties it when the link is gone', async () => { const { NodeEditor, ClassicPreset } = await import('rete'); const { exportGraph } = await import('./rete-editor'); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index e2dd2c8..2308348 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -846,7 +846,7 @@ async function replaceResourceLink(editor: NodeEditor, stepNodeId: st } /** Keep a node's visible Resource state in step with the one allowed resource connection. */ -async function syncResourceLinkPresentation( +export async function syncResourceLinkPresentation( editor: NodeEditor, area: AreaPlugin, runtime: ReteRuntimeContext, @@ -855,15 +855,23 @@ async function syncResourceLinkPresentation( if (getGraphConnectionKind(connection.sourceOutput, connection.targetInput) !== 'resource') return; const target = editor.getNode(connection.target) as HFNode | undefined; - const reference = resourceRefOf(runtime, target); - if (!target?.data || !reference) return; + const targetData = target?.data as Record | undefined; + // A block rebuilt after a structural setting update may no longer have its descriptor in the + // client cache, but addBlockToEditor has already recorded the field that owns this link. + const rememberedField = typeof targetData?.['__resourceRefField'] === 'string' + ? targetData['__resourceRefField'] + : null; + const reference = resourceRefOf(runtime, target) + ?? (rememberedField ? { field: rememberedField, resourceKind: '' } : null); + if (!target || !targetData || !reference) return; const activeLink = editor.getConnections().find((candidate) => candidate.target === target.id && candidate.targetInput === FLOW_RESOURCE_LINK_PORT_KEY); const source = activeLink ? editor.getNode(activeLink.source) as HFNode | undefined : undefined; const resourceId = typeof source?.data?.id === 'string' ? source.data.id : null; - const resourceType = typeof source?.data?.typeName === 'string' ? source.data.typeName : null; - const targetData = target.data as Record; + const resourceType = typeof source?.data?.typeName === 'string' && source.data.typeName.trim() + ? source.data.typeName + : null; const configuration = targetData['specificConfiguration'] as Record | undefined; const resourceChanged = configuration?.[reference.field] !== resourceId; if (!resourceChanged && configuration?.['resourceType'] === resourceType) return; @@ -871,8 +879,8 @@ async function syncResourceLinkPresentation( targetData['specificConfiguration'] = { ...(configuration ?? {}), [reference.field]: resourceId, - ...(targetData['typeName'] === 'StorageOperation' ? { resourceType } : {}), - ...(resourceChanged && targetData['typeName'] === 'StorageOperation' ? { operationId: null, settings: {} } : {}) + ...(reference.field === 'storageNode' ? { resourceType } : {}), + ...(resourceChanged && reference.field === 'storageNode' ? { operationId: null, settings: {} } : {}) }; await area.update('node', target.id); } From c8085dad5aa55375327bd72cd620d30c7e6f5e8e Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 07:52:12 +0200 Subject: [PATCH 05/14] feat(editor): highlight selected connection endpoints --- .../graph-selection/graph-selection.ts | 17 +++++++-- .../custom-connection/custom-connection.ts | 25 ++++++++++--- src/app/shared/custom-socket/custom-socket.ts | 36 ++++++++++++++++++- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/app/services/graph-selection/graph-selection.ts b/src/app/services/graph-selection/graph-selection.ts index 7de9e48..ba233ac 100644 --- a/src/app/services/graph-selection/graph-selection.ts +++ b/src/app/services/graph-selection/graph-selection.ts @@ -4,17 +4,30 @@ import { Injectable, signal } from '@angular/core'; +export type SelectedConnection = { + id: string; + sourceId: string; + sourceOutput: string; + targetId: string; + targetInput: string; +}; + @Injectable({ providedIn: 'root' }) export class GraphSelectionService { readonly selectedConnectionId = signal(null); + /** The two endpoints let their socket components light up with the selected wire. */ + readonly selectedConnection = signal(null); readonly deleteConnectionRequestTick = signal(0); - selectConnection(connectionId: string | null | undefined) { - this.selectedConnectionId.set(connectionId ? String(connectionId) : null); + selectConnection(connectionId: string | null | undefined, endpoints?: Omit) { + const id = connectionId ? String(connectionId) : null; + this.selectedConnectionId.set(id); + this.selectedConnection.set(id && endpoints ? { id, ...endpoints } : null); } clearConnectionSelection() { this.selectedConnectionId.set(null); + this.selectedConnection.set(null); } requestDeleteSelectedConnection() { diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index 91db8cb..b9a5dac 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -20,7 +20,9 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v selector: 'app-custom-connection', standalone: true, template: ` - + >(ElementRef); + private readonly graphSelection = inject(GraphSelectionService); private resizeObserver: ResizeObserver | null = null; private renderFrame: number | null = null; private side: "input" | "output" = "input"; @@ -33,6 +35,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy { @HostBinding("style.width") get width() { + if (this.isSelectedEndpoint) return this.kind === "resource" ? "11px" : "12px"; return this.kind === "resource" ? "7px" : "8px"; } @@ -45,7 +48,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy { @HostBinding("style.borderRadius") get borderRadius() { return this.kind === "resource" ? "4px" : "999px"; } - @HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)"; + @HostBinding("style.border") get border() { + return this.isSelectedEndpoint ? "2px solid #fff" : "1px solid rgba(255,255,255,0.9)"; + } + + @HostBinding('attr.data-connection-endpoint-selected') get selectedEndpointAttribute() { + return this.isSelectedEndpoint || null; + } @HostBinding("style.cursor") get cursor() { return this.data?.__readonly === true ? "default" : "crosshair"; } @@ -56,6 +65,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy { @HostBinding("style.background") get bg() { + if (this.isSelectedEndpoint) { + return this.kind === 'resource' + ? 'linear-gradient(145deg, #5eead4 0%, #0f9f8d 100%)' + : this.kind === 'dependency' + ? 'linear-gradient(145deg, #a5b4fc 0%, #4f46e5 100%)' + : 'linear-gradient(145deg, #fdba74 0%, #ea580c 100%)'; + } if (this.kind === "resource") { return "linear-gradient(180deg, #5eead4 0%, #0f766e 100%)"; } @@ -71,6 +87,12 @@ export class CustomSocket implements AfterViewInit, OnDestroy { @HostBinding("style.boxShadow") get sh() { + if (this.isSelectedEndpoint) { + const color = this.kind === 'resource' ? 'rgba(15,159,141,0.78)' + : this.kind === 'dependency' ? 'rgba(79,70,229,0.72)' + : 'rgba(234,88,12,0.72)'; + return `0 0 0 4px ${color}, 0 5px 14px ${color}`; + } if (this.kind === "resource") { return "0 1px 4px rgba(15,118,110,0.32), 0 0 0 1px rgba(13,148,136,0.42)"; } @@ -92,6 +114,18 @@ export class CustomSocket implements AfterViewInit, OnDestroy { return kind === "dependency" || kind === "resource" ? kind : "data"; } + /** True for either end of the selected data, dependency or resource connection. */ + get isSelectedEndpoint(): boolean { + const selected = this.graphSelection.selectedConnection(); + if (!selected) return false; + const nodeId = this.data?.nodeId ?? this.data?.payload?.nodeId; + const key = this.data?.key ?? this.data?.payload?.key; + if (nodeId == null || key == null) return false; + return this.side === 'output' + ? String(nodeId) === selected.sourceId && String(key) === selected.sourceOutput + : String(nodeId) === selected.targetId && String(key) === selected.targetInput; + } + ngOnChanges(): void { this.side = this.resolveSocketSide(); this.kind = this.resolveSocketKind(); From 03b50ef9896b3a402faa55ca855df7e2a37b13ec Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 07:56:09 +0200 Subject: [PATCH 06/14] fix(editor): raise only selected connection --- src/app/shared/custom-connection/custom-connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index b9a5dac..0256620 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -22,7 +22,7 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v template: ` + [style.z-index]="isSelected ? 100 : null"> Date: Wed, 30 Sep 2026 08:10:53 +0200 Subject: [PATCH 07/14] style(editor): distinguish resource and dependency wires --- src/app/shared/custom-connection/custom-connection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index 0256620..214d4ce 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -305,8 +305,8 @@ export class CustomConnectionComponent { } if (this.isBiasRoutingChange) return '#b45309'; if (this.isLoopBack) return '#7c3aed'; - if (this.isResourceLink) return '#0f766e'; - return this.isDependencyConnection ? '#7c8ba1' : '#4682b4'; + if (this.isResourceLink) return '#0891b2'; + return this.isDependencyConnection ? '#7c3aed' : '#4682b4'; } get strokeWidth(): number { From aeead4bc6c320613f27a53c8e895e5152ab2cb35 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 08:23:55 +0200 Subject: [PATCH 08/14] feat(editor): name connected storage resources --- src/app/shared/nodes/generic-node/generic-node.css | 2 ++ src/app/shared/nodes/generic-node/generic-node.html | 4 ++-- src/app/shared/nodes/generic-node/generic-node.ts | 10 ++++++++++ src/app/shared/nodes/task-step-node/task-step-node.css | 2 ++ .../shared/nodes/task-step-node/task-step-node.html | 4 ++-- src/app/shared/nodes/task-step-node/task-step-node.ts | 10 ++++++++++ src/app/utilities/rete-editor.spec.ts | 3 ++- src/app/utilities/rete-editor.ts | 9 ++++++++- 8 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 267feb7..e091879 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -1082,6 +1082,8 @@ border-color: #14b8a6; background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%); box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14); + letter-spacing: 0.04em; + text-transform: none; } .llm-pill-resource--connected .bi { diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 5225744..30dbcb9 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -231,7 +231,7 @@ refComponent class="llm-pill llm-pill-input llm-pill-resource" [class.llm-pill-resource--connected]="resourceLinkIsConnected" - [attr.title]="resourceLinkIsConnected ? 'Resource connected — drag another Resource here to replace it' : 'Drag a Resource here'" + [attr.title]="resourceLinkIsConnected ? 'Connected to ' + connectedResourceName + ' — drag another Resource here to replace it' : 'Drag a Resource here'" [data]="{ type: 'socket', side: 'input', @@ -243,7 +243,7 @@ [emit]="emit"> @if (resourceLinkIsConnected) { - Resource connected + Connected to {{ connectedResourceName }} } @else { Resource } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index acb53a0..7b4150c 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -283,6 +283,16 @@ export class GenericNodeComponent implements OnDestroy { return typeof value === 'string' && value.trim().length > 0; } + /** The Resource node named by the handle, retained by the canvas when the link is drawn. */ + get connectedResourceName(): string { + const nodeData = this.data?.data as Record | undefined; + const name = nodeData?.['__connectedResourceName']; + if (typeof name === 'string' && name.trim()) return name.trim(); + const field = nodeData?.['__resourceRefField']; + const id = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null; + return typeof id === 'string' && id.trim() ? id : 'Resource'; + } + get dependantOutput(): RenderedSocketPort | null { const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY]; return output diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 152f6be..236ca9f 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -885,6 +885,8 @@ button.llm-node-bias-summary:focus-visible { border-color: #14b8a6; background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%); box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14); + letter-spacing: 0.04em; + text-transform: none; } .llm-pill-resource--connected .bi { diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index ed6f727..b294d5d 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -259,7 +259,7 @@ refComponent class="llm-pill llm-pill-input llm-pill-resource" [class.llm-pill-resource--connected]="storageLinkIsConnected" - [attr.title]="storageLinkIsConnected ? 'Resource connected' : 'Resource'" + [attr.title]="storageLinkIsConnected ? 'Connected to ' + connectedResourceName : 'Resource'" [data]="{ type: 'socket', side: 'input', @@ -271,7 +271,7 @@ [emit]="emit"> @if (storageLinkIsConnected) { - Resource connected + Connected to {{ connectedResourceName }} } @else { Resource } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index e176ce9..57863d0 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -181,6 +181,16 @@ export class TaskStepNodeComponent { return typeof value === 'string' && value.trim().length > 0; } + /** The Resource node named by the handle, retained by the canvas when the link is drawn. */ + get connectedResourceName(): string { + const nodeData = this.data?.data as Record | undefined; + const name = nodeData?.['__connectedResourceName']; + if (typeof name === 'string' && name.trim()) return name.trim(); + const field = nodeData?.['__resourceRefField']; + const id = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null; + return typeof id === 'string' && id.trim() ? id : 'Resource'; + } + get dependantOutput(): { key: string; socket: ClassicPreset.Socket } | null { const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY]; return output diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index 97abb85..3b37cfe 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -97,7 +97,7 @@ describe('the link between a resource and a step that uses it', () => { s3.data = { id: 's3', typeName: 'S3Storage', nodeFamily: 'resource' }; s3.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket)); const postgres = new ClassicPreset.Node('PostgreSQL') as any; - postgres.data = { id: 'postgres', typeName: 'PostgresStorage', nodeFamily: 'resource' }; + postgres.data = { id: 'postgres', name: 'Analytics database', typeName: 'PostgresStorage', nodeFamily: 'resource' }; postgres.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket)); const operation = new ClassicPreset.Node('Operation') as any; operation.data = { @@ -124,6 +124,7 @@ describe('the link between a resource and a step that uses it', () => { expect(operation.data.specificConfiguration).toMatchObject({ storageNode: 'postgres', resourceType: 'PostgresStorage', operationId: null, settings: {} }); + expect(operation.data.__connectedResourceName).toBe('Analytics database'); expect(area.update).toHaveBeenCalledWith('node', operation.id); }); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 2308348..71ff6b8 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -872,10 +872,17 @@ export async function syncResourceLinkPresentation( const resourceType = typeof source?.data?.typeName === 'string' && source.data.typeName.trim() ? source.data.typeName : null; + const resourceName = typeof source?.data?.name === 'string' && source.data.name.trim() + ? source.data.name + : typeof source?.data?.specificConfiguration?.name === 'string' && source.data.specificConfiguration.name.trim() + ? source.data.specificConfiguration.name + : resourceId; const configuration = targetData['specificConfiguration'] as Record | undefined; const resourceChanged = configuration?.[reference.field] !== resourceId; - if (!resourceChanged && configuration?.['resourceType'] === resourceType) return; + const nameChanged = targetData['__connectedResourceName'] !== resourceName; + if (!resourceChanged && configuration?.['resourceType'] === resourceType && !nameChanged) return; + targetData['__connectedResourceName'] = resourceName; targetData['specificConfiguration'] = { ...(configuration ?? {}), [reference.field]: resourceId, From f33712a2506ce521e704000caadad4cfd0f53ab9 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 08:34:45 +0200 Subject: [PATCH 09/14] feat(editor): raise selected connection endpoints --- .../shared/rete-editor/rete-editor.spec.ts | 22 +++++++++++++++++++ src/app/shared/rete-editor/rete-editor.ts | 16 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/app/shared/rete-editor/rete-editor.spec.ts b/src/app/shared/rete-editor/rete-editor.spec.ts index 62f7c7d..98ed1c7 100644 --- a/src/app/shared/rete-editor/rete-editor.spec.ts +++ b/src/app/shared/rete-editor/rete-editor.spec.ts @@ -44,6 +44,7 @@ describe('ReteEditor', () => { deleteConnectionRequestTick: vi.fn(), loopLimitRequest: vi.fn().mockReturnValue(null), selectedConnectionId: vi.fn().mockReturnValue(null), + selectedConnection: vi.fn().mockReturnValue(null), clearConnectionSelection: vi.fn() } } @@ -70,6 +71,27 @@ describe('ReteEditor', () => { expect(component).toBeTruthy(); }); + it('brings both endpoints of a selected connection above unrelated nodes', async () => { + const source = document.createElement('div'); + const target = document.createElement('div'); + const reorder = vi.fn().mockResolvedValue(undefined); + const rete = { + area: { + nodeViews: new Map([ + ['source', { element: source }], + ['target', { element: target }] + ]), + area: { content: { reorder } } + } + } as any; + (component as any).rete = rete; + + await (component as any).bringConnectionEndpointsToFront(rete, 'source', 'target'); + + expect(reorder).toHaveBeenNthCalledWith(1, source, null); + expect(reorder).toHaveBeenNthCalledWith(2, target, null); + }); + it('does not render the dormant swimlane UI', () => { fixture.componentRef.setInput('flowData', { blocks: [ diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index b540ae9..59ea620 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -53,6 +53,12 @@ export class ReteEditor implements OnChanges, OnDestroy { if (!request || !rete || isReadonly) return; void this.applyLoopLimit(rete, request.connectionId, request.maxIterations); }); + effect(() => { + const selected = this.graphSelection.selectedConnection(); + const rete = untracked(() => this.rete); + if (!selected || !rete) return; + void this.bringConnectionEndpointsToFront(rete, selected.sourceId, selected.targetId); + }); } private async applyLoopLimit(rete: ReteEditorInstance, connectionId: string, maxIterations: number | null) { @@ -60,6 +66,16 @@ export class ReteEditor implements OnChanges, OnDestroy { this.flowState.updateData(exportGraph(rete.editor), { structural: true }); } + /** Keep both endpoint cards above unrelated nodes while their selected wire is in front. */ + private async bringConnectionEndpointsToFront(rete: ReteEditorInstance, sourceId: string, targetId: string) { + for (const nodeId of new Set([sourceId, targetId])) { + if (this.rete !== rete) return; + const view = rete.area.nodeViews.get(nodeId); + if (!view) continue; + await rete.area.area.content.reorder(view.element, null); + } + } + readonly container = viewChild.required('editor'); readonly shell = viewChild.required>('shell'); From 09abd7e3f6967562381ba20bf286d80bb4f6e013 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 08:44:34 +0200 Subject: [PATCH 10/14] fix(editor): keep loop routes at a fixed height --- .../custom-connection.spec.ts | 12 +++++++++ .../custom-connection/custom-connection.ts | 2 +- src/app/utilities/rete-editor.ts | 25 ++++++------------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/app/shared/custom-connection/custom-connection.spec.ts b/src/app/shared/custom-connection/custom-connection.spec.ts index c0a2aeb..f559cec 100644 --- a/src/app/shared/custom-connection/custom-connection.spec.ts +++ b/src/app/shared/custom-connection/custom-connection.spec.ts @@ -65,4 +65,16 @@ describe('CustomConnectionComponent bias routing highlight', () => { comparisonViewState.show({ report: REPORT }); expect(component.strokeColor).toBe('#4682b4'); }); + + it('keeps a loop at its assigned height while a connected node moves above it', () => { + component.data = { + ...component.data, + __loopBack: true, + __loopTop: 120 + } as any; + component.start = { x: 0, y: 40 }; + component.end = { x: 100, y: 80 }; + + expect(component.drawnPath).toContain('L 100 120'); + }); }); diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index 214d4ce..20da801 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -267,7 +267,7 @@ export class CustomConnectionComponent { private get loopTop(): number { const ports = Math.min(this.start?.y ?? 0, this.end?.y ?? 0) - 90; const nodes = this.loopData?.__loopTop; - return typeof nodes === 'number' ? Math.min(nodes, ports) : ports; + return typeof nodes === 'number' ? nodes : ports; } get loopLabelX(): number { diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 71ff6b8..648bce6 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -222,20 +222,6 @@ export async function createEditor( return context; }); - area.addPipe((context: any) => { - if (context?.type === 'nodetranslated') { - // Any node moved can be one the way back has to clear, not only one of its two ends. - for (const connection of editor.getConnections() as LoopAwareConnection[]) { - if (!connection.__loopBack) continue; - const top = loopTop(area, connection); - if (top === connection.__loopTop) continue; - connection.__loopTop = top; - void area.update('connection', connection.id); - } - } - return context; - }); - connection.addPreset(ConnectionPresets.classic.setup()); AreaExtensions.simpleNodesOrder(area); @@ -975,7 +961,13 @@ export async function refreshLoopMarkers( for (const connection of connections) { const loopBack = backEdges.has(connection.id); const readonly = resolvedRuntime?.readonly === true; - const top = loopBack ? loopTop(area, connection) : undefined; + // A loop gets its route when it becomes a loop. It then keeps that canvas height while cards + // move, so a person can drag a card across the wire instead of the wire perpetually fleeing up. + const top = loopBack + ? connection.__loopBack === true && typeof connection.__loopTop === 'number' + ? connection.__loopTop + : loopTop(area, connection) + : undefined; if (connection.__loopBack === loopBack && connection.__readonly === readonly && connection.__loopTop === top) continue; connection.__loopBack = loopBack; connection.__readonly = readonly; @@ -985,8 +977,7 @@ export async function refreshLoopMarkers( } /** - * Above the tops of the nodes at both ends. Their tops, not their bottoms: a node's height changes - * as it is expanded or edited, its position only when it is moved. + * The initial route of a loop: above the nodes it has to avoid when the loop is first established. */ function loopTop(area: AreaPlugin, connection: LoopAwareConnection): number | undefined { const ends = [connection.source, connection.target] From 11bcfb59737d31d8e069e81fe36a916ff51bdd06 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 08:50:57 +0200 Subject: [PATCH 11/14] fix(editor): route loops from endpoint heights --- src/app/utilities/rete-editor.spec.ts | 35 ++++++++++++++++++++++++++- src/app/utilities/rete-editor.ts | 12 +++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index 3b37cfe..d806eba 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/f import { HFNode } from '@models/nodes'; import { vi } from 'vitest'; -import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor'; +import { isResourceNode, refreshLoopMarkers, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor'; function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode { return { data: { typeName, nodeFamily } } as unknown as HFNode; @@ -87,6 +87,39 @@ describe('isResourceNode', () => { }); }); +describe('loop route placement', () => { + it('starts 48px above its highest endpoint, irrespective of a higher node between them', async () => { + const start = { id: 'start', data: { typeName: 'Start', nodeFamily: 'block' } } as any; + const router = { id: 'router', data: { typeName: 'Router', nodeFamily: 'block' } } as any; + const intermediate = { id: 'intermediate', data: { typeName: 'Step', nodeFamily: 'block' } } as any; + const forward = { id: 'forward', source: 'start', sourceOutput: 'out', target: 'router', targetInput: 'in' } as any; + const loop = { id: 'loop', source: 'router', sourceOutput: 'out', target: 'start', targetInput: 'in', loop: {} } as any; + const editor = { + getNodes: () => [start, router, intermediate], + getConnections: () => [forward, loop] + } as any; + const area = { + nodeViews: new Map([ + ['start', { position: { x: 0, y: 500 }, element: { offsetWidth: 100 } }], + ['router', { position: { x: 400, y: 400 }, element: { offsetWidth: 100 } }], + ['intermediate', { position: { x: 200, y: 100 }, element: { offsetWidth: 100 } }] + ]), + update: vi.fn().mockResolvedValue(undefined) + } as any; + const runtime = makeRuntime({ + blocksService: { + peekBlockType: vi.fn().mockImplementation((type: string) => + type === 'Router' ? { capabilities: { ...DEFAULT_NODE_CAPABILITIES, routesExclusively: true } } : null) + } as any + }); + + await refreshLoopMarkers(editor, area, runtime); + + expect(loop.__loopBack).toBe(true); + expect(loop.__loopTop).toBe(352); + }); +}); + describe('the link between a resource and a step that uses it', () => { it('replaces an S3 operation choice with PostgreSQL choices when its resource changes', async () => { const { NodeEditor, ClassicPreset } = await import('rete'); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 648bce6..7db4779 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -977,21 +977,15 @@ export async function refreshLoopMarkers( } /** - * The initial route of a loop: above the nodes it has to avoid when the loop is first established. + * The initial route of a loop: 48px above its highest endpoint. Nodes in between are deliberately + * ignored, since they may subsequently be moved across this fixed route. */ function loopTop(area: AreaPlugin, connection: LoopAwareConnection): number | undefined { const ends = [connection.source, connection.target] .map((id) => area.nodeViews.get(id)) .filter((view): view is NonNullable => view != null); if (!ends.length) return undefined; - // The way back runs across everything between its two ends, so it has to clear the tallest of - // those nodes too - the loop's own steps usually sit there - not only the two it connects. - const left = Math.min(...ends.map((view) => view.position.x)); - const right = Math.max(...ends.map((view) => view.position.x + view.element.offsetWidth)); - const tops = [...area.nodeViews.values()] - .filter((view) => view.position.x + view.element.offsetWidth >= left && view.position.x <= right) - .map((view) => view.position.y); - return Math.min(...tops, ...ends.map((view) => view.position.y)) - 48; + return Math.min(...ends.map((view) => view.position.y)) - 48; } /** From 26b11a0203e5dfb04b3c002fa10e597b50d33938 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 08:53:16 +0200 Subject: [PATCH 12/14] fix(editor): follow only loop endpoints --- src/app/utilities/rete-editor.spec.ts | 8 ++++++++ src/app/utilities/rete-editor.ts | 27 ++++++++++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index d806eba..7f2e08c 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -117,6 +117,14 @@ describe('loop route placement', () => { expect(loop.__loopBack).toBe(true); expect(loop.__loopTop).toBe(352); + + area.nodeViews.get('intermediate').position.y = 0; + await refreshLoopMarkers(editor, area, runtime); + expect(loop.__loopTop).toBe(352); + + area.nodeViews.get('router').position.y = 300; + await refreshLoopMarkers(editor, area, runtime); + expect(loop.__loopTop).toBe(252); }); }); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 7db4779..73fb065 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -222,6 +222,21 @@ export async function createEditor( return context; }); + area.addPipe((context: any) => { + if (context?.type === 'nodetranslated') { + // A loop follows only its own endpoints. A card merely crossed by the wire must not make the + // wire move away, while moving either endpoint keeps the wire 48px above that endpoint. + for (const connection of editor.getConnections() as LoopAwareConnection[]) { + if (!connection.__loopBack) continue; + const top = loopTop(area, connection); + if (top === connection.__loopTop) continue; + connection.__loopTop = top; + void area.update('connection', connection.id); + } + } + return context; + }); + connection.addPreset(ConnectionPresets.classic.setup()); AreaExtensions.simpleNodesOrder(area); @@ -961,13 +976,7 @@ export async function refreshLoopMarkers( for (const connection of connections) { const loopBack = backEdges.has(connection.id); const readonly = resolvedRuntime?.readonly === true; - // A loop gets its route when it becomes a loop. It then keeps that canvas height while cards - // move, so a person can drag a card across the wire instead of the wire perpetually fleeing up. - const top = loopBack - ? connection.__loopBack === true && typeof connection.__loopTop === 'number' - ? connection.__loopTop - : loopTop(area, connection) - : undefined; + const top = loopBack ? loopTop(area, connection) : undefined; if (connection.__loopBack === loopBack && connection.__readonly === readonly && connection.__loopTop === top) continue; connection.__loopBack = loopBack; connection.__readonly = readonly; @@ -977,8 +986,8 @@ export async function refreshLoopMarkers( } /** - * The initial route of a loop: 48px above its highest endpoint. Nodes in between are deliberately - * ignored, since they may subsequently be moved across this fixed route. + * A loop runs 48px above its highest endpoint. Nodes in between are deliberately ignored, so they + * may be moved across the wire without causing it to move. */ function loopTop(area: AreaPlugin, connection: LoopAwareConnection): number | undefined { const ends = [connection.source, connection.target] From bcc9009278ae6c0abddece90c7d7e973e63be2a5 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 09:11:05 +0200 Subject: [PATCH 13/14] fix(editor): clear wire selection before node drag --- src/app/utilities/rete-editor.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 73fb065..7fc14ed 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -36,6 +36,7 @@ import { import { BlocksService } from "@services/blocks/blocks"; import { ContainersService } from "@services/containers/containers"; import { ResourcesService } from "@services/resources/resources"; +import { GraphSelectionService } from "@services/graph-selection/graph-selection"; import { EditorStateHolder } from "@stores/flow-editor"; import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node"; import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node"; @@ -107,6 +108,7 @@ export async function createEditor( const area = new AreaPlugin(container); const connection = new ConnectionPlugin(); const render = new AngularPlugin({ injector }); + const graphSelection = injector.get(GraphSelectionService); const nodeView = options?.nodeView ?? "editor"; const readonly = options?.readonly === true; const programmaticTranslations = new Set(); @@ -223,6 +225,9 @@ export async function createEditor( }); area.addPipe((context: any) => { + // The connection renderer raises only the selected wire. A picked node is about to be dragged, + // so clear that selection before its card can be drawn below the wire. + if (context?.type === 'nodepicked') graphSelection.clearConnectionSelection(); if (context?.type === 'nodetranslated') { // A loop follows only its own endpoints. A card merely crossed by the wire must not make the // wire move away, while moving either endpoint keeps the wire 48px above that endpoint. From 4e0d2347632b3a508e0f7882e7ee29460a3f78a5 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 30 Sep 2026 09:17:58 +0200 Subject: [PATCH 14/14] fix(editor): retain wire selection for endpoints --- src/app/utilities/rete-editor.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 7fc14ed..46c61da 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -225,9 +225,15 @@ export async function createEditor( }); area.addPipe((context: any) => { - // The connection renderer raises only the selected wire. A picked node is about to be dragged, - // so clear that selection before its card can be drawn below the wire. - if (context?.type === 'nodepicked') graphSelection.clearConnectionSelection(); + // The connection renderer raises only the selected wire. Clear it before dragging an unrelated + // card, but keep it when the picked card is one of the wire's own endpoints. + if (context?.type === 'nodepicked') { + const selected = graphSelection.selectedConnection(); + const nodeId = String(context.data?.id ?? ''); + if (!selected || (nodeId !== selected.sourceId && nodeId !== selected.targetId)) { + graphSelection.clearConnectionSelection(); + } + } if (context?.type === 'nodetranslated') { // A loop follows only its own endpoints. A card merely crossed by the wire must not make the // wire move away, while moving either endpoint keeps the wire 48px above that endpoint.