fix(editor): render container subflows and long port labels

This commit is contained in:
Lucio Lelii 2026-08-03 17:21:28 +02:00
parent 15b411c992
commit 14f78b79ce
7 changed files with 191 additions and 8 deletions

View File

@ -0,0 +1,89 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '@environment';
import { firstValueFrom } from 'rxjs';
import { AssistantCallService } from './assistant-call';
describe('AssistantCallService', () => {
let service: AssistantCallService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AssistantCallService,
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(AssistantCallService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('normalizes node families in assistant drafts and nested container subflows', async () => {
const request = firstValueFrom(service.getSession('session-1'));
httpMock.expectOne(`${environment.apiUrl}/assistant/sessions/session-1`).flush({
id: 'session-1',
selectedModel: 'model-1',
messages: [],
currentDraftFlow: {
name: 'Loop draft',
flow: {
blocks: [{ id: 'root-block', typeName: 'LLMBlock', specificConfiguration: {} }],
containers: [{
id: 'loop-1',
typeName: 'LoopContainer',
specificConfiguration: {
subFlow: {
blocks: [{ id: 'body-block', typeName: 'LLMBlock', specificConfiguration: {} }],
containers: [{
id: 'nested-container',
typeName: 'GenericContainer',
specificConfiguration: {
subFlow: { blocks: [], containers: [], connections: [], dependencies: [] }
}
}],
connections: [],
dependencies: []
},
guardSubFlow: {
blocks: [{ id: 'guard-block', typeName: 'SwitchBlock', specificConfiguration: {} }],
containers: [],
connections: [],
dependencies: []
}
}
}],
connections: [],
dependencies: []
}
}
});
const session = await request;
const flow = session.currentDraftFlow!.flow;
const loopConfiguration = flow.containers[0].specificConfiguration as Record<string, any>;
expect(flow.blocks[0].nodeFamily).toBe('block');
expect(flow.containers[0].nodeFamily).toBe('container');
expect(loopConfiguration['subFlow'].blocks[0].nodeFamily).toBe('block');
expect(loopConfiguration['subFlow'].containers[0].nodeFamily).toBe('container');
expect(loopConfiguration['subFlow'].containers[0].specificConfiguration.subFlow).toEqual({
blocks: [],
containers: [],
connections: [],
dependencies: [],
globalInputs: [],
lanes: []
});
expect(loopConfiguration['guardSubFlow'].blocks[0].nodeFamily).toBe('block');
});
});

View File

@ -203,14 +203,63 @@ function hasAssistantFlowData(flow: AssistantDraftPayload['flow']): boolean {
function mapAssistantFlowData(raw: unknown): AssistantDraftPayload['flow'] {
const value = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
return {
blocks: Array.isArray(value['blocks']) ? (value['blocks'] as any[]) : [],
containers: Array.isArray(value['containers']) ? (value['containers'] as any[]) : [],
blocks: normalizeAssistantFlowNodes(value['blocks'], 'block'),
containers: normalizeAssistantFlowNodes(value['containers'], 'container'),
connections: Array.isArray(value['connections']) ? (value['connections'] as any[]) : [],
dependencies: Array.isArray(value['dependencies']) ? (value['dependencies'] as any[]) : [],
globalInputs: Array.isArray(value['globalInputs']) ? (value['globalInputs'] as any[]) : []
globalInputs: Array.isArray(value['globalInputs']) ? (value['globalInputs'] as any[]) : [],
lanes: Array.isArray(value['lanes']) ? (value['lanes'] as any[]) : []
};
}
function normalizeAssistantFlowNodes(
raw: unknown,
nodeFamily: 'block'
): AssistantDraftPayload['flow']['blocks'];
function normalizeAssistantFlowNodes(
raw: unknown,
nodeFamily: 'container'
): AssistantDraftPayload['flow']['containers'];
function normalizeAssistantFlowNodes(
raw: unknown,
nodeFamily: 'block' | 'container'
): AssistantDraftPayload['flow']['blocks'] | AssistantDraftPayload['flow']['containers'] {
if (!Array.isArray(raw)) return [];
return raw
.filter((node): node is Record<string, unknown> =>
!!node && typeof node === 'object' && !Array.isArray(node)
)
.map((node) => ({
...node,
nodeFamily,
specificConfiguration: normalizeAssistantFlowValue(node['specificConfiguration'])
})) as AssistantDraftPayload['flow']['blocks'] | AssistantDraftPayload['flow']['containers'];
}
function normalizeAssistantFlowValue(raw: unknown): unknown {
if (Array.isArray(raw)) {
return raw.map((item) => normalizeAssistantFlowValue(item));
}
if (!raw || typeof raw !== 'object') return raw;
const value = raw as Record<string, unknown>;
if (isAssistantFlowData(value)) {
return mapAssistantFlowData(value);
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, normalizeAssistantFlowValue(item)])
);
}
function isAssistantFlowData(value: Record<string, unknown>): boolean {
return ['blocks', 'containers', 'connections'].every((key) =>
Object.prototype.hasOwnProperty.call(value, key)
&& (value[key] == null || Array.isArray(value[key]))
);
}
function mapValidationIssues(raw: unknown): AssistantValidationIssue[] {
if (!Array.isArray(raw)) return [];

View File

@ -440,6 +440,7 @@
font-size: 12px;
font-weight: 600;
box-sizing: border-box;
overflow: hidden;
}
.container-node__port-label--input {
@ -475,6 +476,13 @@
}
.container-node__port-context {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
line-height: 1.1;
font-weight: 700;
@ -483,6 +491,13 @@
}
.container-node__port-name {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}

View File

@ -137,7 +137,7 @@
}"
[emit]="emit">
</div>
<span class="container-node__port-label container-node__port-label--input">
<span class="container-node__port-label container-node__port-label--input" [title]="inputDisplayLabel(input.key)">
@if (inputDisplayLabelParts(input.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}
@ -152,7 +152,7 @@
<div class="container-node__port-title">Outputs</div>
@for (output of outputs; track output.key) {
<div class="container-node__port-row container-node__port-row--right">
<span class="container-node__port-label container-node__port-label--output">
<span class="container-node__port-label container-node__port-label--output" [title]="outputDisplayLabel(output.key)">
@if (outputDisplayLabelParts(output.key).context; as context) {
<span class="container-node__port-context">{{ context }}</span>
}

View File

@ -580,6 +580,8 @@
.llm-pill {
min-height: 32px;
width: 100%;
max-width: 100%;
border-radius: 7px;
border: 1px solid transparent;
font-size: 11px;
@ -589,14 +591,18 @@
align-items: center;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
.llm-pill-meta {
width: 100%;
max-width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
overflow: hidden;
}
.llm-pill-meta-output {
@ -604,6 +610,9 @@
}
.llm-pill-name {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@ -624,7 +633,7 @@
background: #ecfdf3;
border-color: #bbf7d0;
gap: 4px;
overflow: visible;
overflow: hidden;
}
.llm-node--human .llm-pill-input {

View File

@ -140,7 +140,7 @@
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input">
<span class="llm-pill llm-pill-input" [title]="inputDisplayLabel(input.key)">
<span class="llm-pill-meta">
<span class="llm-pill-name">{{ inputDisplayLabel(input.key) }}</span>
@if (!isReadonly && canTogglePortMultiplicity('input', input.key)) {
@ -167,7 +167,7 @@
<div class="llm-column-title llm-column-title-right">{{ outputsTitle() }}</div>
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)" [title]="outputDisplayLabel(output.key)">
<span class="llm-pill-meta llm-pill-meta-output">
<span class="llm-pill-name">{{ outputDisplayLabel(output.key) }}</span>
@if (!isReadonly && canTogglePortMultiplicity('output', output.key)) {

View File

@ -153,6 +153,27 @@ describe('GenericNodeComponent', () => {
expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']);
});
it('keeps long port labels available as tooltips', () => {
const longInput = 'input.with.a.very.long.descriptive.label.that.must.not.expand.the.node';
const longOutput = 'output.with.a.very.long.descriptive.label.that.must.not.expand.the.node';
fixture.componentRef.setInput('data', {
...component.data,
inputs: { [longInput]: { socket: { name: 'TEXT' } } },
outputs: { [longOutput]: { socket: { name: 'TEXT' } } },
data: {
...component.data.data,
inputs: [{ name: longInput, type: 'TEXT', multiple: false }],
outputs: [{ name: longOutput, type: 'TEXT', multiple: false }]
}
});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.llm-row-input .llm-pill')?.getAttribute('title')).toBe(longInput);
expect(host.querySelector('.llm-row-output .llm-pill')?.getAttribute('title')).toBe(longOutput);
});
it('keeps a flexible port kind-selectable after narrowing it once', () => {
fixture.componentRef.setInput('data', {
...component.data,