diff --git a/src/app/services/assistant/assistant-call.spec.ts b/src/app/services/assistant/assistant-call.spec.ts new file mode 100644 index 0000000..a4fecbe --- /dev/null +++ b/src/app/services/assistant/assistant-call.spec.ts @@ -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; + + 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'); + }); +}); diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index 3560045..6cbf977 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -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; 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 => + !!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; + if (isAssistantFlowData(value)) { + return mapAssistantFlowData(value); + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, normalizeAssistantFlowValue(item)]) + ); +} + +function isAssistantFlowData(value: Record): 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 []; diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 9d23099..87c4373 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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; } diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 091d155..8bfb32a 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -137,7 +137,7 @@ }" [emit]="emit"> - + @if (inputDisplayLabelParts(input.key).context; as context) { {{ context }} } @@ -152,7 +152,7 @@
Outputs
@for (output of outputs; track output.key) {
- + @if (outputDisplayLabelParts(output.key).context; as context) { {{ context }} } diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index e21ead7..7f0f647 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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 { diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 8eee4c6..625bda4 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -140,7 +140,7 @@ }" [emit]="emit">
- + {{ inputDisplayLabel(input.key) }} @if (!isReadonly && canTogglePortMultiplicity('input', input.key)) { @@ -167,7 +167,7 @@
{{ outputsTitle() }}
@for (output of outputs; track output.key) {
- + {{ outputDisplayLabel(output.key) }} @if (!isReadonly && canTogglePortMultiplicity('output', output.key)) { 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 e9fb424..c35b24c 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -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,