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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 19:32:03 +02:00
parent f4a526ebbd
commit aa3f54bb1a
9 changed files with 325 additions and 124 deletions

View File

@ -371,62 +371,15 @@
</button>
}
@if (item.arrayField; as arrayField) {
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="addArrayItem(arrayField.path, $event)">
<i class="bi bi-plus-lg"></i>
</button>
}
</div>
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
} @else {
@for (entry of arrayField.items; track entry.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ entry.summary }}</span>
<div class="llm-array-item-actions">
@if (entry.definition; as definition) {
<button
type="button"
class="llm-edit-btn"
title="View content"
(pointerdown)="$event.stopPropagation()"
(click)="viewItemDefinition(definition, $event)">
<i class="bi bi-eye"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="editArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-pen"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
title="Remove item"
(pointerdown)="$event.stopPropagation()"
(click)="removeArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
}
}
</div>
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="isReadonly"
(add)="addArrayItem(arrayField.path)"
(edit)="editArrayItem(arrayField.path, $event)"
(remove)="removeArrayItem(arrayField.path, $event)"
(view)="viewItemDefinition($any($event))" />
}
}
}
@ -527,62 +480,15 @@
</button>
}
@if (item.arrayField; as arrayField) {
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="addArrayItem(arrayField.path, $event)">
<i class="bi bi-plus-lg"></i>
</button>
}
</div>
@if (!arrayField.items.length) {
<div class="llm-array-empty">No items</div>
} @else {
@for (entry of arrayField.items; track entry.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ entry.summary }}</span>
<div class="llm-array-item-actions">
@if (entry.definition; as definition) {
<button
type="button"
class="llm-edit-btn"
title="View content"
(pointerdown)="$event.stopPropagation()"
(click)="viewItemDefinition(definition, $event)">
<i class="bi bi-eye"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="editArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-pen"></i>
</button>
}
@if (!isReadonly) {
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
title="Remove item"
(pointerdown)="$event.stopPropagation()"
(click)="removeArrayItem(arrayField.path, entry.index, $event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
}
}
</div>
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="isReadonly"
(add)="addArrayItem(arrayField.path)"
(edit)="editArrayItem(arrayField.path, $event)"
(remove)="removeArrayItem(arrayField.path, $event)"
(view)="viewItemDefinition($any($event))" />
}
}
</div>

View File

@ -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);
});

View File

@ -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: {

View File

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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;
}

View File

@ -0,0 +1,67 @@
<!--
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
SPDX-License-Identifier: AGPL-3.0-or-later
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
-->
<div class="llm-param-row-head">
<button
type="button"
class="llm-array-toggle"
[disabled]="!items.length"
[attr.aria-expanded]="expanded()"
[attr.title]="items.length ? (expanded() ? 'Hide ' : 'Show ') + label.toLowerCase() : null"
(pointerdown)="$event.stopPropagation()"
(click)="toggle($event)">
<i class="bi llm-array-chevron" [class.bi-chevron-down]="expanded()" [class.bi-chevron-right]="!expanded()"></i>
<span class="llm-param-key">{{ label }}</span>
<span class="llm-array-count">{{ items.length }}</span>
</button>
@if (!readonly) {
<button
type="button"
class="llm-edit-btn"
[attr.title]="'Add ' + label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="emit(add, undefined, $event)">
<i class="bi bi-plus-lg"></i>
</button>
}
</div>
@if (expanded() && items.length) {
@for (entry of items; track entry.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ entry.summary }}</span>
<div class="llm-array-item-actions">
@if (entry.definition) {
<button
type="button"
class="llm-edit-btn"
title="View content"
(pointerdown)="$event.stopPropagation()"
(click)="emit(view, entry.definition, $event)">
<i class="bi bi-eye"></i>
</button>
}
@if (!readonly) {
<button
type="button"
class="llm-edit-btn"
title="Edit item"
(pointerdown)="$event.stopPropagation()"
(click)="emit(edit, entry.index, $event)">
<i class="bi bi-pen"></i>
</button>
<button
type="button"
class="llm-edit-btn llm-array-remove-btn"
title="Remove item"
(pointerdown)="$event.stopPropagation()"
(click)="emit(remove, entry.index, $event)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
}
}

View File

@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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<NodeListFieldComponent>;
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();
});
});

View File

@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
*
* <p>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<void>();
@Output() readonly edit = new EventEmitter<number>();
@Output() readonly remove = new EventEmitter<number>();
@Output() readonly view = new EventEmitter<unknown>();
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<T>(emitter: EventEmitter<T>, value: T, event: Event) {
event.preventDefault();
event.stopPropagation();
emitter.emit(value);
}
}

View File

@ -406,16 +406,7 @@
<div class="llm-array-sections">
@for (arrayField of arrayFields; track arrayField.path) {
@if (arrayField.items.length) {
<div class="llm-array-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ arrayField.label }}</div>
</div>
@for (item of arrayField.items; track item.index) {
<div class="llm-array-item">
<span class="llm-array-item-summary">{{ item.summary }}</span>
</div>
}
</div>
<app-node-list-field class="llm-array-block" [label]="arrayField.label" [items]="arrayField.items" [readonly]="true" />
}
}
</div>

View File

@ -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: {