diff --git a/src/app/layouts/tasks-executor/tasks-executor.css b/src/app/layouts/tasks-executor/tasks-executor.css index 478abc9..6241b77 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.css +++ b/src/app/layouts/tasks-executor/tasks-executor.css @@ -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); + } +} diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index 7a42142..0483fae 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -1,4 +1,4 @@ -
+
- + + @if (showExecutionCreationLoader()) { +
+
+ +
Creating execution...
+
The task view is ready. Waiting for the new execution to appear.
+
+
+ }
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 39e01ea..e1cc4f1 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -32,6 +32,7 @@ export class TasksExecutor { ); readonly executionDetails = this.taskExecutionsService.taskExecutions; + readonly pendingExecutionCreation = this.taskExecutionsService.pendingExecutionCreation; readonly executions = computed(() => 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); diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index 7ca57ae..6442d30 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -12,6 +12,7 @@ export class BlocksService { toInit: boolean = true; private loadingPromise: Promise | null = null; + private readonly _catalogLoading = signal(false); private readonly emptyBlockCache = new Map(); private readonly pendingEmptyBlockRequests = new Map>(); private readonly pendingServerSyncCount = signal(0); @@ -19,6 +20,7 @@ export class BlocksService { private _blockTypes = signal([]); 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); diff --git a/src/app/services/containers/containers.ts b/src/app/services/containers/containers.ts index 7a384b2..8a20729 100644 --- a/src/app/services/containers/containers.ts +++ b/src/app/services/containers/containers.ts @@ -12,6 +12,7 @@ export class ContainersService { toInit = true; private loadingPromise: Promise | null = null; + private readonly _catalogLoading = signal(false); private readonly emptyContainerCache = new Map(); private readonly pendingEmptyContainerRequests = new Map>(); private readonly pendingServerSyncCount = signal(0); @@ -19,6 +20,7 @@ export class ContainersService { private _containerTypes = signal([]); 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); diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index b5e5485..c5f905a 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -15,8 +15,10 @@ export class TaskExecutionsService { private refreshInFlight = false; private pollTimer: ReturnType | null = null; private _taskExecutions = signal([]); + 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); diff --git a/src/app/shared/blocks-list/blocks-list.html b/src/app/shared/blocks-list/blocks-list.html index 64b04a4..f4be6d9 100644 --- a/src/app/shared/blocks-list/blocks-list.html +++ b/src/app/shared/blocks-list/blocks-list.html @@ -1,4 +1,4 @@ -@if (loading()) { +@if (showLoading()) {
diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts index 04c0ddf..b55cc3e 100644 --- a/src/app/shared/blocks-list/blocks-list.ts +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -25,6 +25,7 @@ export class BlocksList extends ListStateViewHolder { private blocksService = inject(BlocksService); loading: WritableSignal = signal(true); + readonly serviceLoading = this.blocksService.catalogLoading; /* get orderView() { @@ -75,6 +76,8 @@ export class BlocksList extends ListStateViewHolder { ); }); + readonly showLoading = computed(() => this.loading() || this.serviceLoading()); + onDragStart(event: DragEvent, block: BlockType) { if (!event.dataTransfer) return; event.dataTransfer.effectAllowed = 'copy'; diff --git a/src/app/shared/containers-list/containers-list.html b/src/app/shared/containers-list/containers-list.html index 864f1dd..164dcdd 100644 --- a/src/app/shared/containers-list/containers-list.html +++ b/src/app/shared/containers-list/containers-list.html @@ -1,4 +1,4 @@ -@if (loading()) { +@if (showLoading()) {
diff --git a/src/app/shared/containers-list/containers-list.ts b/src/app/shared/containers-list/containers-list.ts index 559959c..1170e8d 100644 --- a/src/app/shared/containers-list/containers-list.ts +++ b/src/app/shared/containers-list/containers-list.ts @@ -24,6 +24,7 @@ export class ContainersList extends ListStateViewHolder { private containersService = inject(ContainersService); loading: WritableSignal = signal(true); + readonly serviceLoading = this.containersService.catalogLoading; containerTypes?: Signal; constructor() { @@ -67,6 +68,8 @@ export class ContainersList extends ListStateViewHolder { ); }); + readonly showLoading = computed(() => this.loading() || this.serviceLoading()); + onDragStart(event: DragEvent, container: BlockType) { if (!event.dataTransfer) return; event.dataTransfer.effectAllowed = 'copy'; diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index ec5c4d4..5e43536 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -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; + } } diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts index 5b778f6..06931f7 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts @@ -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); - } } diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.html b/src/app/shared/node-settings-dialog/node-settings-dialog.html index 26653b2..8566a6a 100644 --- a/src/app/shared/node-settings-dialog/node-settings-dialog.html +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.html @@ -1,5 +1,5 @@ @if (state()) { -
+
diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index e01367f..534bd1e 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -1,4 +1,12 @@
+ @if (isSchemaLoading && !isAssigning) { +
+
+ + Loading container... +
+
+ } @if (deleteConfirmOpen) {
} @@ -222,6 +230,15 @@ (click)="openParameterEditor(field.path, $event)"> + } @else if (field.expandable) { + }
{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} @@ -231,6 +248,16 @@
{{ contentField.label }} + @if (contentField.expandable && isReadonly) { + + }
@for (part of contentField.parts; track $index) { diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index e155317..5235574 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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 | null { const value = this.data?.data?.specificConfiguration; return value && typeof value === 'object' ? value as Record : 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 | 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; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 209a503..484c13c 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -10,6 +10,14 @@
} + @if (isSchemaLoading && !isCreatingOnServer()) { +
+
+ + Loading block... +
+
+ } @if (deleteConfirmOpen) {
} @@ -252,6 +260,15 @@ (click)="openParameterEditor(field.path, $event)"> + } @else if (field.expandable) { + }
{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} @@ -291,6 +308,15 @@ (click)="openParameterEditor(field.path, $event)"> + } @else if (field.expandable) { + }
{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} @@ -308,6 +334,15 @@ + } @else if (contentField.expandable) { + }
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 04c7d4a..8d24e4a 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 | 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()); diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index aa9203c..8673171 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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({