Improve execution handoff and node loading feedback

This commit is contained in:
Lucio Lelii 2026-03-30 17:06:18 +02:00
parent d508ccf6d1
commit bb1cdd52ac
18 changed files with 299 additions and 16 deletions

View File

@ -2,3 +2,64 @@
display: block;
height: 100%;
}
.tasks-executor-shell {
min-height: 0;
}
.tasks-executor-viewer {
position: relative;
}
.tasks-executor-loader {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(243, 244, 246, 0.82);
backdrop-filter: blur(2px);
}
.tasks-executor-loader-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 360px;
padding: 24px 28px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.12);
text-align: center;
}
.tasks-executor-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(15, 23, 42, 0.12);
border-top-color: #2563eb;
border-radius: 999px;
animation: tasks-executor-spin 0.75s linear infinite;
}
.tasks-executor-loader-title {
color: #0f172a;
font-size: 1rem;
font-weight: 700;
}
.tasks-executor-loader-text {
color: #475569;
font-size: 0.92rem;
line-height: 1.4;
}
@keyframes tasks-executor-spin {
to {
transform: rotate(360deg);
}
}

View File

@ -1,4 +1,4 @@
<div class="flex flex-col flex-1 h-full">
<div class="tasks-executor-shell flex flex-col flex-1 h-full">
<div class="flex flex-row flex-1 overflow-hidden gap-2">
<app-tasks-executions-list
class="w-[360px] shrink-0"
@ -8,7 +8,16 @@
(executionDeleteRequested)="removeExecution($event)">
</app-tasks-executions-list>
<mat-card class="flex w-full flex-col overflow-hidden !rounded-md !bg-gray-100">
<mat-card class="tasks-executor-viewer flex w-full flex-col overflow-hidden !rounded-md !bg-gray-100">
@if (showExecutionCreationLoader()) {
<div class="tasks-executor-loader">
<div class="tasks-executor-loader-card">
<span class="tasks-executor-spinner" aria-hidden="true"></span>
<div class="tasks-executor-loader-title">Creating execution...</div>
<div class="tasks-executor-loader-text">The task view is ready. Waiting for the new execution to appear.</div>
</div>
</div>
}
<app-task-execution-viewer [execution]="selectedExecution()"></app-task-execution-viewer>
</mat-card>
</div>

View File

@ -32,6 +32,7 @@ export class TasksExecutor {
);
readonly executionDetails = this.taskExecutionsService.taskExecutions;
readonly pendingExecutionCreation = this.taskExecutionsService.pendingExecutionCreation;
readonly executions = computed<TaskExecutionListItem[]>(() =>
this.executionDetails().map((execution) => ({
@ -56,6 +57,10 @@ export class TasksExecutor {
return details.find((execution) => execution.id === selectedId) ?? null;
});
readonly showExecutionCreationLoader = computed(() =>
this.pendingExecutionCreation() && !this.requestedExecutionId()
);
constructor() {
void this.blocksService.getAllBlocksTypes().catch((err) => {
console.error('Error preloading block types for task executor', err);

View File

@ -12,6 +12,7 @@ export class BlocksService {
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private readonly _catalogLoading = signal(false);
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private readonly pendingServerSyncCount = signal(0);
@ -19,6 +20,7 @@ export class BlocksService {
private _blockTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly blockTypes = this._blockTypes.asReadonly();
readonly catalogLoading = this._catalogLoading.asReadonly();
hasLoadedBlockTypes() {
return this._blockTypes().length > 0 || !this.toInit;
@ -39,6 +41,9 @@ export class BlocksService {
}
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.finally(() => {
this._catalogLoading.set(false);
})
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
@ -51,6 +56,8 @@ export class BlocksService {
this.loadingPromise = null;
});
this._catalogLoading.set(true);
return this.loadingPromise;
}
@ -58,7 +65,11 @@ export class BlocksService {
const current = this._blockTypes().find((blockType) => blockType.type === typeName);
if (current) return current;
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes());
this._catalogLoading.set(true);
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.finally(() => {
this._catalogLoading.set(false);
});
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
return blockTypes.find((blockType) => blockType.type === typeName);

View File

@ -12,6 +12,7 @@ export class ContainersService {
toInit = true;
private loadingPromise: Promise<void> | null = null;
private readonly _catalogLoading = signal(false);
private readonly emptyContainerCache = new Map<string, FlowNode>();
private readonly pendingEmptyContainerRequests = new Map<string, Observable<FlowNode>>();
private readonly pendingServerSyncCount = signal(0);
@ -19,6 +20,7 @@ export class ContainersService {
private _containerTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly containerTypes = this._containerTypes.asReadonly();
readonly catalogLoading = this._catalogLoading.asReadonly();
hasLoadedContainerTypes() {
return this._containerTypes().length > 0 || !this.toInit;
@ -39,6 +41,9 @@ export class ContainersService {
}
this.loadingPromise = firstValueFrom(this.containersCallService.retrieveAllContainerTypes())
.finally(() => {
this._catalogLoading.set(false);
})
.then((containerTypes) => {
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
@ -51,6 +56,8 @@ export class ContainersService {
this.loadingPromise = null;
});
this._catalogLoading.set(true);
return this.loadingPromise;
}
@ -58,7 +65,11 @@ export class ContainersService {
const current = this._containerTypes().find((containerType) => containerType.type === typeName);
if (current) return current;
const containerTypes = await firstValueFrom(this.containersCallService.retrieveAllContainerTypes());
this._catalogLoading.set(true);
const containerTypes = await firstValueFrom(this.containersCallService.retrieveAllContainerTypes())
.finally(() => {
this._catalogLoading.set(false);
});
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
return containerTypes.find((containerType) => containerType.type === typeName);

View File

@ -15,8 +15,10 @@ export class TaskExecutionsService {
private refreshInFlight = false;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private _taskExecutions = signal<TaskExecution[]>([]);
private _pendingExecutionCreation = signal(false);
taskExecutions = this._taskExecutions.asReadonly();
pendingExecutionCreation = this._pendingExecutionCreation.asReadonly();
init() {
if (this.initialized) return;
@ -48,7 +50,9 @@ export class TaskExecutionsService {
}
createExecution(flowId: string) {
this._pendingExecutionCreation.set(true);
return this.taskExecutionsCallService.createTaskExecution(flowId).pipe(
finalize(() => this._pendingExecutionCreation.set(false)),
tap(() => this.refresh()),
catchError((err) => {
console.error('Create execution failed', err);

View File

@ -1,4 +1,4 @@
@if (loading()) {
@if (showLoading()) {
<div class="blocks-list-loading">
<mat-spinner diameter="32"></mat-spinner>
</div>

View File

@ -25,6 +25,7 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
private blocksService = inject(BlocksService);
loading: WritableSignal<boolean> = signal(true);
readonly serviceLoading = this.blocksService.catalogLoading;
/*
get orderView() {
@ -75,6 +76,8 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
);
});
readonly showLoading = computed(() => this.loading() || this.serviceLoading());
onDragStart(event: DragEvent, block: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';

View File

@ -1,4 +1,4 @@
@if (loading()) {
@if (showLoading()) {
<div class="blocks-list-loading">
<mat-spinner diameter="32"></mat-spinner>
</div>

View File

@ -24,6 +24,7 @@ export class ContainersList extends ListStateViewHolder<BlockType> {
private containersService = inject(ContainersService);
loading: WritableSignal<boolean> = signal(true);
readonly serviceLoading = this.containersService.catalogLoading;
containerTypes?: Signal<BlockType[]>;
constructor() {
@ -67,6 +68,8 @@ export class ContainersList extends ListStateViewHolder<BlockType> {
);
});
readonly showLoading = computed(() => this.loading() || this.serviceLoading());
onDragStart(event: DragEvent, container: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';

View File

@ -45,8 +45,9 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly modelPickerOpen = signal(false);
readonly quickPromptsOpen = signal(true);
readonly editorHasOpenFlow = computed(() => !!this.currentFlow());
readonly editorHasNonEmptyFlow = computed(() => this.hasMeaningfulFlow(this.currentFlow()));
readonly sessionHasDraft = computed(() => !!this.currentDraft());
readonly canOfferCreate = computed(() => !this.editorHasOpenFlow() && !this.sessionHasDraft());
readonly canOfferCreate = computed(() => !this.editorHasNonEmptyFlow() && !this.sessionHasDraft());
readonly canOfferFix = computed(() => !this.canOfferCreate() && (this.sessionState()?.lastValidationErrors?.length ?? 0) > 0);
readonly assistantModeLabel = computed(() => this.canOfferCreate() ? 'Create with assistant' : 'Refine with assistant');
readonly assistantModeDescription = computed(() => {
@ -420,4 +421,13 @@ export class FlowAssistant implements OnInit, OnDestroy {
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
}
private hasMeaningfulFlow(flow: Flow | null): boolean {
if (!flow) return false;
const data = flow.data;
return (data.blocks?.length ?? 0) > 0
|| (data.containers?.length ?? 0) > 0
|| (data.connections?.length ?? 0) > 0
|| (data.dependencies?.length ?? 0) > 0;
}
}

View File

@ -117,7 +117,7 @@ export class HumanInteractionDialogHostComponent {
confirmInput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.closeWith({ mode: 'complete', value: this.state()?.currentInput ?? '' });
this.dialog.submit({ mode: 'complete', value: this.state()?.currentInput ?? '' });
}
sendEditedOutput(event?: Event) {
@ -125,7 +125,7 @@ export class HumanInteractionDialogHostComponent {
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'complete', value: this.draftValue });
this.dialog.submit({ mode: 'complete', value: this.draftValue });
}
sendChatMessage(event?: Event) {
@ -165,8 +165,4 @@ export class HumanInteractionDialogHostComponent {
}
return deduplicated;
}
private closeWith(value: HumanInteractionDialogResult) {
this.dialog.close(value);
}
}

View File

@ -1,5 +1,5 @@
@if (state()) {
<div class="fixed inset-0 z-9999">
<div class="fixed inset-0" style="z-index: 10010;">
<div class="absolute inset-0 bg-black/50" (click)="cancel($event)"></div>
<div class="absolute left-1/2 top-1/2 flex max-h-[min(80vh,720px)] w-[min(92vw,560px)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">

View File

@ -1,4 +1,12 @@
<div class="container-node" [class.container-node-assigning]="isAssigning" [class.container-node-delete-pending]="deleteConfirmOpen">
@if (isSchemaLoading && !isAssigning) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
<span class="container-node__spinner" aria-hidden="true"></span>
<span>Loading container...</span>
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="container-node__delete-overlay"></div>
}
@ -222,6 +230,15 @@
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="container-node__param-value" [class.container-node__param-value--clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
@ -231,6 +248,16 @@
<div class="container-node__param-chip container-node__param-chip--wide">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ contentField.label }}</span>
@if (contentField.expandable && isReadonly) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="container-node__param-value" [class.container-node__param-value--clamped]="contentField.expandable">
@for (part of contentField.parts; track $index) {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, HostListener, Input, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
@ -87,9 +87,14 @@ export class ContainerNodeComponent {
parameterFields: ContainerFieldView[] = [];
richContentFields: RichContentView[] = [];
schemaReady = false;
private schemaLoading = false;
nameEditorOpen = false;
draftName = '';
get isSchemaLoading() {
return this.schemaLoading;
}
@Input() data!: any;
@Input() emit!: (data: any) => void;
@Input() rendered!: () => void;
@ -114,6 +119,12 @@ export class ContainerNodeComponent {
void this.loadSchemaContext();
}
@HostListener('click')
onNodeClick() {
if (!this.shouldRetrySchemaLoad()) return;
void this.loadSchemaContext();
}
ngAfterViewInit() {
this.rendered();
}
@ -535,6 +546,20 @@ export class ContainerNodeComponent {
this.subflowPreview.open(this.subFlow, `${this.name} subflow`);
}
async openFieldPreview(field: ContainerFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.value);
}
async openMainContentPreview(field: RichContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.rawValue);
}
private get configuration(): Record<string, unknown> | null {
const value = this.data?.data?.specificConfiguration;
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
@ -553,6 +578,25 @@ export class ContainerNodeComponent {
return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0;
}
private async openReadonlyTextDialog(label: string, value: string) {
await this.settingsDialog.open({
title: label,
previewOnly: true,
fields: [
{
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value
}
});
}
private assignSelectionToContainer(payload: string[]) {
this.importErrorMessage = null;
const assign = this.data?.data?.assignSelectedBlocksToContainer;
@ -664,6 +708,9 @@ export class ContainerNodeComponent {
}
private async loadSchemaContext() {
if (this.schemaLoading) return;
this.schemaLoading = true;
this.schemaReady = false;
try {
const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
@ -671,6 +718,7 @@ export class ContainerNodeComponent {
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
this.schemaLoading = false;
this.schemaReady = true;
queueMicrotask(() => {
try {
@ -682,6 +730,11 @@ export class ContainerNodeComponent {
}
}
private shouldRetrySchemaLoad(): boolean {
if (this.schemaLoading) return false;
return !this.containerSchema || this.containerFieldDefinitions.length === 0;
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;

View File

@ -10,6 +10,14 @@
</div>
</div>
}
@if (isSchemaLoading && !isCreatingOnServer()) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
<span class="llm-spinner" aria-hidden="true"></span>
<span>Loading block...</span>
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="llm-delete-overlay"></div>
}
@ -252,6 +260,15 @@
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
@ -291,6 +308,15 @@
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
@ -308,6 +334,15 @@
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, HostListener, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
@ -199,6 +199,11 @@ export class GenericNodeComponent {
localEditorBindableInputName: string | null = null;
deleteConfirmOpen = false;
schemaReady = false;
private schemaLoading = false;
get isSchemaLoading() {
return this.schemaLoading;
}
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
@ -244,6 +249,12 @@ export class GenericNodeComponent {
void this.loadSchemaContext();
}
@HostListener('click')
onNodeClick() {
if (!this.shouldRetrySchemaLoad()) return;
void this.loadSchemaContext();
}
ngAfterViewInit() {
this.rendered();
}
@ -578,12 +589,15 @@ export class GenericNodeComponent {
}
private async loadSchemaContext() {
if (this.schemaLoading) return;
const type = this.blockType;
if (!type) {
this.schemaReady = true;
return;
}
this.schemaLoading = true;
this.schemaReady = false;
try {
const blockType = this.blocksService.peekBlockType(type) ?? await this.blocksService.getBlockType(type);
this.blockDescriptor = blockType ?? null;
@ -598,10 +612,17 @@ export class GenericNodeComponent {
this.refreshValidationState();
this.maybeCreateBlockOnServer();
} finally {
this.schemaLoading = false;
this.schemaReady = true;
}
}
private shouldRetrySchemaLoad(): boolean {
if (this.schemaLoading) return false;
if (!this.blockType) return false;
return !this.blockSchema || (!this.editableFieldDefinitions.length && !this.arrayFieldDefinitions.length);
}
private async openTextareaEditor(
path: string,
label: string,
@ -1093,6 +1114,20 @@ export class GenericNodeComponent {
}
}
async openFieldPreview(field: EditableFieldView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.value);
}
async openMainContentPreview(field: RichContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
await this.openReadonlyTextDialog(field.label, field.rawValue);
}
private resolveSelectableOptions(definition: EditableFieldDefinition): NodeSettingOption[] {
if (definition.nodeOptionsSource) {
return this.resolveNodeOptions(definition.nodeOptionsSource);
@ -1122,6 +1157,25 @@ export class GenericNodeComponent {
return String(value ?? '').trim().length > 80;
}
private async openReadonlyTextDialog(label: string, value: string) {
await this.settingsDialog.open({
title: label,
previewOnly: true,
fields: [
{
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value
}
});
}
private refreshParameterFields() {
const config = this.blockConfiguration ?? {};
const richContentPaths = new Set(this.richContentPaths());

View File

@ -121,6 +121,7 @@ export class TitleToolbar {
if (!flow || !this.canExecute() || this.executeLoading()) return;
this.executeLoading.set(true);
void this.router.navigate(['/tasks']);
this.taskExecutionsService.createExecution(flow.id).pipe(
take(1)
).subscribe({