From aa3f54bb1a02637b5a49bdeb6677c526fc14d72f Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 25 Sep 2026 19:32:03 +0200 Subject: [PATCH] Show a node's list parameters closed, with how many items they hold Every list parameter of a node now reads as its name and a count, and opens on demand to its items; with none there is nothing to open, the add button is the way in, and the "No items" line is gone. An item added opens the list, so it is there to see. The list is one shared component, app-node-list-field, rather than two near-identical blocks in the block node and a third in the execution view: the same list reads and behaves the same everywhere, and a container that gains a list parameter takes the component as it is. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nodes/generic-node/generic-node.html | 130 +++--------------- .../nodes/generic-node/generic-node.spec.ts | 5 + .../shared/nodes/generic-node/generic-node.ts | 3 +- .../nodes/node-list-field/node-list-field.css | 102 ++++++++++++++ .../node-list-field/node-list-field.html | 67 +++++++++ .../node-list-field/node-list-field.spec.ts | 65 +++++++++ .../nodes/node-list-field/node-list-field.ts | 63 +++++++++ .../nodes/task-step-node/task-step-node.html | 11 +- .../nodes/task-step-node/task-step-node.ts | 3 +- 9 files changed, 325 insertions(+), 124 deletions(-) create mode 100644 src/app/shared/nodes/node-list-field/node-list-field.css create mode 100644 src/app/shared/nodes/node-list-field/node-list-field.html create mode 100644 src/app/shared/nodes/node-list-field/node-list-field.spec.ts create mode 100644 src/app/shared/nodes/node-list-field/node-list-field.ts diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index ccea647..c1dff1b 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -371,62 +371,15 @@ } @if (item.arrayField; as arrayField) { -
-
-
{{ arrayField.label }}
- @if (!isReadonly) { - - } -
- @if (!arrayField.items.length) { -
No items
- } @else { - @for (entry of arrayField.items; track entry.index) { -
- {{ entry.summary }} -
- @if (entry.definition; as definition) { - - } - @if (!isReadonly) { - - } - @if (!isReadonly) { - - } -
-
- } - } -
+ } } } @@ -527,62 +480,15 @@ } @if (item.arrayField; as arrayField) { -
-
-
{{ arrayField.label }}
- @if (!isReadonly) { - - } -
- @if (!arrayField.items.length) { -
No items
- } @else { - @for (entry of arrayField.items; track entry.index) { -
- {{ entry.summary }} -
- @if (entry.definition; as definition) { - - } - @if (!isReadonly) { - - } - @if (!isReadonly) { - - } -
-
- } - } -
+ } } 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 eb89ded..b8408b9 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -519,6 +519,11 @@ describe('GenericNodeComponent', () => { } ]; component.cdr.detectChanges(); + // A list starts closed, showing only its count; opened, its rows offer the button. + for (const toggle of fixture.nativeElement.querySelectorAll('.llm-array-toggle')) { + (toggle as HTMLButtonElement).click(); + } + fixture.detectChanges(); const buttons = fixture.nativeElement.querySelectorAll('[title="View content"]'); expect(buttons.length).toBe(2); }); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index f92bcc6..2d6a521 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -73,6 +73,7 @@ import { primitiveItemType, type PrimitiveItemType } from '../schema-driven-fields'; +import { NodeListFieldComponent } from '../node-list-field/node-list-field'; type FieldType = SchemaFieldType; @@ -151,7 +152,7 @@ type RenderedSocketPort = { @Component({ selector: 'app-generic-node', - imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent], + imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent, NodeListFieldComponent], templateUrl: './generic-node.html', styleUrl: './generic-node.css', host: { diff --git a/src/app/shared/nodes/node-list-field/node-list-field.css b/src/app/shared/nodes/node-list-field/node-list-field.css new file mode 100644 index 0000000..d7cc41b --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.css @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR + * SPDX-License-Identifier: AGPL-3.0-or-later + * Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + */ + +:host { + display: block; +} + +.llm-param-row-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-width: 0; +} + +.llm-edit-btn { + border: none; + background: transparent; + color: #64748b; + border-radius: 999px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; +} + +.llm-edit-btn:hover { + color: #2563eb; + background: rgba(37, 99, 235, 0.1); + transform: translateY(-1px); +} + +.llm-edit-btn:active { + transform: translateY(0); +} + +.llm-edit-btn:disabled { + opacity: 0.45; + cursor: default; + pointer-events: none; +} + +/* A list shows its size first and its items on demand; with none there is nothing to open. */ +.llm-array-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.llm-array-toggle:disabled { + cursor: default; +} + +.llm-array-toggle .llm-param-key { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.llm-array-chevron { + flex: 0 0 auto; + color: #64748b; + font-size: 10px; +} + +.llm-array-toggle:disabled .llm-array-chevron { + opacity: 0.35; +} + +.llm-array-count { + flex: 0 0 auto; + min-width: 18px; + padding: 1px 6px; + border-radius: 999px; + background: #e0e7ff; + color: #3730a3; + font-size: 10px; + font-weight: 600; + line-height: 1.4; + text-align: center; +} + +.llm-array-toggle:disabled .llm-array-count { + background: #f1f5f9; + color: #94a3b8; +} diff --git a/src/app/shared/nodes/node-list-field/node-list-field.html b/src/app/shared/nodes/node-list-field/node-list-field.html new file mode 100644 index 0000000..85d8550 --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.html @@ -0,0 +1,67 @@ + + +
+ + @if (!readonly) { + + } +
+@if (expanded() && items.length) { + @for (entry of items; track entry.index) { +
+ {{ entry.summary }} +
+ @if (entry.definition) { + + } + @if (!readonly) { + + + } +
+
+ } +} diff --git a/src/app/shared/nodes/node-list-field/node-list-field.spec.ts b/src/app/shared/nodes/node-list-field/node-list-field.spec.ts new file mode 100644 index 0000000..26a4f6d --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.spec.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NodeListFieldComponent, NodeListFieldItem } from './node-list-field'; + +describe('NodeListFieldComponent', () => { + let fixture: ComponentFixture; + const item = (index: number, summary: string): NodeListFieldItem => ({ index, summary }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [NodeListFieldComponent] }).compileComponents(); + fixture = TestBed.createComponent(NodeListFieldComponent); + fixture.componentRef.setInput('label', 'Skills'); + }); + + const el = () => fixture.nativeElement as HTMLElement; + const toggle = () => el().querySelector('.llm-array-toggle') as HTMLButtonElement; + + it('starts closed, showing how many items there are', () => { + fixture.componentRef.setInput('items', [item(0, 'a'), item(1, 'b')]); + fixture.detectChanges(); + + expect(el().querySelector('.llm-array-count')?.textContent?.trim()).toBe('2'); + expect(el().querySelectorAll('.llm-array-item').length).toBe(0); + + toggle().click(); + fixture.detectChanges(); + expect(el().querySelectorAll('.llm-array-item').length).toBe(2); + }); + + it('has nothing to open when empty, and says nothing about it', () => { + fixture.componentRef.setInput('items', []); + fixture.detectChanges(); + + expect(toggle().disabled).toBe(true); + expect(el().querySelector('.llm-array-count')?.textContent?.trim()).toBe('0'); + expect(el().textContent).not.toContain('No items'); + expect(el().querySelector('[title="Add skills"]')).not.toBeNull(); + }); + + it('opens when an item is added, so the new one is there to see', () => { + fixture.componentRef.setInput('items', []); + fixture.detectChanges(); + + fixture.componentRef.setInput('items', [item(0, 'mcp-context-economy')]); + fixture.detectChanges(); + + expect(el().querySelector('.llm-array-item-summary')?.textContent).toContain('mcp-context-economy'); + }); + + it('read-only, shows the items and nothing to change them with', () => { + fixture.componentRef.setInput('items', [item(0, 'a')]); + fixture.componentRef.setInput('readonly', true); + fixture.detectChanges(); + toggle().click(); + fixture.detectChanges(); + + expect(el().querySelector('[title="Add skills"]')).toBeNull(); + expect(el().querySelector('[title="Edit item"]')).toBeNull(); + expect(el().querySelector('[title="Remove item"]')).toBeNull(); + }); +}); diff --git a/src/app/shared/nodes/node-list-field/node-list-field.ts b/src/app/shared/nodes/node-list-field/node-list-field.ts new file mode 100644 index 0000000..ece95f9 --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, signal } from '@angular/core'; + +export type NodeListFieldItem = { + index: number; + summary: string; + /** Something the item can be opened to read in full, when it has one. */ + definition?: unknown | null; +}; + +/** + * A node parameter that is a list: its name and how many items it has, the items themselves on + * demand. Shared by every node that shows lists - blocks, the execution view, and containers once + * one has a list parameter - so they all read and behave the same. + * + *

Starts closed. With no items there is nothing to open, and the add button is the way in; an + * item added opens the list, so what was just added is there to see rather than only counted. + */ +@Component({ + selector: 'app-node-list-field', + standalone: true, + templateUrl: './node-list-field.html', + styleUrl: './node-list-field.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NodeListFieldComponent implements OnChanges { + @Input({ required: true }) label = ''; + @Input() items: NodeListFieldItem[] = []; + /** Read-only views - an execution - show the items and offer nothing to change them. */ + @Input() readonly = false; + + @Output() readonly add = new EventEmitter(); + @Output() readonly edit = new EventEmitter(); + @Output() readonly remove = new EventEmitter(); + @Output() readonly view = new EventEmitter(); + + readonly expanded = signal(false); + + ngOnChanges(changes: SimpleChanges): void { + const change = changes['items']; + if (!change || change.firstChange) return; + const before = Array.isArray(change.previousValue) ? change.previousValue.length : 0; + const after = this.items?.length ?? 0; + if (after > before) this.expanded.set(true); + if (after === 0) this.expanded.set(false); + } + + toggle(event: Event) { + event.preventDefault(); + event.stopPropagation(); + if (!this.items.length) return; + this.expanded.update((open) => !open); + } + + emit(emitter: EventEmitter, value: T, event: Event) { + event.preventDefault(); + event.stopPropagation(); + emitter.emit(value); + } +} 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 17b7f3a..445b322 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 @@ -406,16 +406,7 @@

@for (arrayField of arrayFields; track arrayField.path) { @if (arrayField.items.length) { -
-
-
{{ arrayField.label }}
-
- @for (item of arrayField.items; track item.index) { -
- {{ item.summary }} -
- } -
+ } }
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 bbc4dec..bf99854 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 @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { NodeListFieldComponent } from '../node-list-field/node-list-field'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject, viewChild } from '@angular/core'; import { ClassicPreset } from 'rete'; @@ -87,7 +88,7 @@ type FieldUiMeta = { @Component({ selector: 'app-task-step-node', - imports: [CommonModule, ReteModule, BiasAnnotationsComponent], + imports: [CommonModule, ReteModule, BiasAnnotationsComponent, NodeListFieldComponent], templateUrl: './task-step-node.html', styleUrl: './task-step-node.css', host: {