From 73312f82c0137dadbcb2e0b4d91ff92bcdddb5ec Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 18 May 2026 12:35:09 +0200 Subject: [PATCH] UI/UX: miglioramenti a flow-editor, flow-assistant e rete-editor (stili, layout, interazioni) --- src/app/layouts/flow-editor/flow-editor.css | 51 +++++++ src/app/layouts/flow-editor/flow-editor.html | 48 +++++-- src/app/layouts/flow-editor/flow-editor.ts | 52 +++++++- .../shared/flow-assistant/flow-assistant.css | 30 +++++ .../shared/flow-assistant/flow-assistant.html | 26 ++-- .../shared/flow-assistant/flow-assistant.ts | 124 ++++++++++++++++-- src/app/shared/rete-editor/rete-editor.ts | 9 ++ 7 files changed, 300 insertions(+), 40 deletions(-) diff --git a/src/app/layouts/flow-editor/flow-editor.css b/src/app/layouts/flow-editor/flow-editor.css index be5e87a..936c502 100644 --- a/src/app/layouts/flow-editor/flow-editor.css +++ b/src/app/layouts/flow-editor/flow-editor.css @@ -220,6 +220,13 @@ line-height: 1.1; } +.flow-editor-create-ai-window-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + .flow-editor-create-ai-close { display: inline-flex; align-items: center; @@ -236,6 +243,19 @@ background: #f1f5f9; } +.flow-editor-create-ai-close:disabled { + border-color: #e2e8f0; + background: #f8fafc; + color: #cbd5e1; + cursor: not-allowed; + opacity: 0.72; + box-shadow: none; +} + +.flow-editor-create-ai-close:disabled:hover { + background: #f8fafc; +} + .flow-editor-create-ai-close .mat-icon { width: 18px; height: 18px; @@ -247,6 +267,37 @@ flex: 1 1 auto; } +.flow-editor-create-ai-floating { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 1299; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: calc(100vw - 48px); + min-height: 44px; + padding: 10px 14px; + border: 1px solid rgba(14, 116, 144, 0.28); + border-radius: 999px; + background: #ffffff; + color: #0f172a; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22); + font-size: 13px; + font-weight: 800; +} + +.flow-editor-create-ai-floating:hover { + background: #f8fafc; +} + +.flow-editor-create-ai-floating .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + color: #0f766e; +} + .flow-editor-tour-target { height: 100%; } diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index a2411eb..2b08285 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -108,25 +108,55 @@ @if (showCreateAssistantModal()) { -
+
} + @if (showCreateAssistantFloatingButton()) { + + } + @if (tourActive() && activeTourStep(); as step) {
diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 1d07fda..f9264c6 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, HostListener, ViewChild, computed, import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { environment } from '@environment'; import { FlowBlock, FlowData } from '@models/flow'; import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar"; @@ -11,6 +12,7 @@ import { Authorization } from '@services/authorization/authorization'; import { BlocksService } from '@services/blocks/blocks'; import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { FlowsService } from '@services/flows/flows'; +import { AssistantSessionStore } from '@stores/assistant-session-store'; import { EditorStateHolder } from '@stores/flow-editor'; import { FlowAssistant } from '@shared/flow-assistant/flow-assistant'; import { FlowValidationPanel } from '@shared/flow-validation-panel/flow-validation-panel'; @@ -28,7 +30,7 @@ type TourStep = { @Component({ selector: 'app-flow-editor', - imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, FlowValidationPanel, MatButtonModule, MatCardModule, MatIconModule], + imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, FlowValidationPanel, MatButtonModule, MatCardModule, MatIconModule, MatTooltipModule], templateUrl: './flow-editor.html', styleUrl: './flow-editor.css', changeDetection: ChangeDetectionStrategy.OnPush @@ -37,12 +39,14 @@ export class FlowEditor { private static readonly SIDEBAR_OPEN_DELAY_MS = 320; private static readonly TOUR_SEEN_KEY_PREFIX = 'editor-guided-tour-seen:'; @ViewChild(EditorSidebar) editorSidebar?: EditorSidebar; + @ViewChild('createAssistant') createAssistant?: FlowAssistant; private editorState: EditorStateHolder = inject(EditorStateHolder); private authorization = inject(Authorization); private flowsService = inject(FlowsService); private blocksService = inject(BlocksService); private confirm = inject(ConfirmDialogService); + private assistantSessionStore = inject(AssistantSessionStore); private tourBootstrapped = false; private demoFlowId: string | null = null; @@ -50,6 +54,8 @@ export class FlowEditor { assistantOpen = signal(true); activeRightPanel = signal<'assistant' | 'errors'>('assistant'); aiCreationRequested = signal(false); + aiCreationMinimized = signal(false); + createAssistantCancellable = signal(false); creatingFlowFromEmpty = signal(false); flow = this.editorState.currentFlow; readonly = this.editorState.isCurrentFlowReadOnly; @@ -59,7 +65,10 @@ export class FlowEditor { this.assistantEnabled && !this.readonly() && !!this.flow() ); showCreateAssistantModal = computed(() => - this.assistantEnabled && this.aiCreationRequested() && !this.flow() + this.assistantEnabled && this.aiCreationRequested() && !this.aiCreationMinimized() && !this.flow() + ); + showCreateAssistantFloatingButton = computed(() => + this.assistantEnabled && this.aiCreationRequested() && this.aiCreationMinimized() && !this.flow() ); tourActive = signal(false); tourStepIndex = signal(0); @@ -152,8 +161,12 @@ export class FlowEditor { effect(() => { if (this.aiCreationRequested() && this.flow()) { this.aiCreationRequested.set(false); + this.aiCreationMinimized.set(false); + this.createAssistantCancellable.set(false); } }); + + queueMicrotask(() => this.restoreMinimizedCreateAssistantIfAvailable()); } toggleAssistant() { @@ -216,12 +229,37 @@ export class FlowEditor { this.editorState.closeDocument(); } this.aiCreationRequested.set(true); + this.aiCreationMinimized.set(false); this.activeRightPanel.set('assistant'); this.assistantOpen.set(true); } - closeCreateWithAi() { + minimizeCreateWithAi() { + if (!this.aiCreationRequested() || !this.createAssistantCancellable()) return; + this.aiCreationMinimized.set(true); + } + + restoreCreateWithAi() { + if (!this.aiCreationRequested()) return; + this.aiCreationMinimized.set(false); + } + + async closeCreateWithAi() { + const assistant = this.createAssistant; + if (assistant?.hasCancellableCall()) { + const confirmed = await this.confirm.open( + 'An assistant request is still running. Cancel it and close AI flow creation?' + ); + if (!confirmed) return; + + const cancelled = await assistant.cancelActiveCall(); + if (!cancelled) return; + assistant.clearActiveSnapshot(); + } + this.aiCreationRequested.set(false); + this.aiCreationMinimized.set(false); + this.createAssistantCancellable.set(false); } async createFlowFromEmpty() { @@ -407,6 +445,14 @@ export class FlowEditor { return localStorage.getItem(`${FlowEditor.TOUR_SEEN_KEY_PREFIX}${username}`) === 'true'; } + private restoreMinimizedCreateAssistantIfAvailable() { + if (!this.assistantEnabled || this.flow() || this.aiCreationRequested()) return; + if (!this.assistantSessionStore.hasSnapshot(AssistantSessionStore.CREATE_MODAL_FLOW_KEY)) return; + + this.aiCreationRequested.set(true); + this.aiCreationMinimized.set(true); + } + private ensureSidebarForStep(): boolean { const section = this.activeTourStep()?.sidebarSection; if (!section) return false; diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css index 374d96b..06f8e03 100644 --- a/src/app/shared/flow-assistant/flow-assistant.css +++ b/src/app/shared/flow-assistant/flow-assistant.css @@ -123,6 +123,36 @@ color: #7f1d1d; } +.assistant-inline-retry { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + padding: 5px 12px; + border: 1px solid rgba(15, 118, 110, 0.28); + border-radius: 999px; + background: #f0fdfa; + color: #0f766e; + font: inherit; + font-size: 12px; + font-weight: 800; + text-transform: lowercase; + cursor: pointer; +} + +.assistant-inline-retry:hover { + border-color: rgba(15, 118, 110, 0.46); + background: #ccfbf1; + color: #115e59; +} + +.assistant-inline-retry:disabled { + border-color: #cbd5e1; + background: #f8fafc; + color: #94a3b8; + cursor: default; +} + .assistant-busy-row { display: flex; align-items: flex-start; diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index 7dc8455..54a753b 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -38,22 +38,6 @@ } - @if (assistantErrorMessage() && !assistantBusy()) { -
-

{{ assistantErrorMessage() }}

- -
- } - - - @if (!progressOnlyMode()) {
@@ -82,6 +66,16 @@
{{ message.role }}

{{ message.content }}

+ @if (canRetryFromMessage(message)) { + + } @if (message.warnings?.length) {
diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index 33fdbf2..49c6c59 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, EventEmitter, inject, input, OnDestroy, OnInit, Output, signal, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -20,7 +20,7 @@ import { AssistantService } from '@services/assistant/assistant'; import { Authorization } from '@services/authorization/authorization'; import { AssistantSessionStore } from '@stores/assistant-session-store'; import { EditorStateHolder } from '@stores/flow-editor'; -import { finalize, interval, Subscription, switchMap, take } from 'rxjs'; +import { finalize, firstValueFrom, interval, Subscription, switchMap, take } from 'rxjs'; @Component({ selector: 'app-flow-assistant', @@ -30,6 +30,7 @@ import { finalize, interval, Subscription, switchMap, take } from 'rxjs'; changeDetection: ChangeDetectionStrategy.OnPush }) export class FlowAssistant implements OnInit, OnDestroy { + @Output() cancellableCallChange = new EventEmitter(); @ViewChild('assistantScroll') assistantScrollElement?: ElementRef; private readonly assistant = inject(AssistantService); @@ -40,7 +41,9 @@ export class FlowAssistant implements OnInit, OnDestroy { private initialized = false; private activeFlowKey: string | null = null; private lastAutoScrollKey = ''; - private readonly createModalFlowKey = `__assistant:create-modal:${crypto.randomUUID()}`; + private lastCancellableCallState = false; + private skipDestroySnapshot = false; + private readonly createModalFlowKey = AssistantSessionStore.CREATE_MODAL_FLOW_KEY; private static readonly STANDARD_ASSISTANT_ERROR = 'Something went wrong while processing your workflow request. Please try again.'; readonly assistantConfig = signal(null); @@ -119,7 +122,8 @@ export class FlowAssistant implements OnInit, OnDestroy { const baseMessages = this.sessionState()?.messages?.length ? this.sessionState()!.messages : [this.systemWelcomeMessage()]; - return [...baseMessages, ...this.localMessages()]; + return [...baseMessages, ...this.localMessages()] + .filter((message) => !this.isTechnicalAssistantFailureMessage(message)); }); readonly assistantBusy = computed(() => { const status = this.currentCall()?.status; @@ -201,6 +205,13 @@ export class FlowAssistant implements OnInit, OnDestroy { this.activeFlowKey = flowKey; void this.restoreSessionForFlow(flowKey); }); + + effect(() => { + const nextState = this.hasCancellableCall(); + if (this.lastCancellableCallState === nextState) return; + this.lastCancellableCallState = nextState; + this.cancellableCallChange.emit(nextState); + }); } ngOnInit(): void { @@ -208,7 +219,7 @@ export class FlowAssistant implements OnInit, OnDestroy { } ngOnDestroy(): void { - if (this.activeFlowKey) { + if (this.activeFlowKey && !this.skipDestroySnapshot) { this.persistSnapshot(this.activeFlowKey); } this.stopPolling(); @@ -246,6 +257,54 @@ export class FlowAssistant implements OnInit, OnDestroy { this.sendPrompt(failedPrompt); } + canRetryFromMessage(message: AssistantChatMessage): boolean { + return message.role === 'assistant' + && this.canRetryLastPrompt() + && this.canonicalAssistantErrorContent(message.content) + === this.canonicalAssistantErrorContent(FlowAssistant.STANDARD_ASSISTANT_ERROR); + } + + hasCancellableCall(): boolean { + return this.isActiveCall(this.currentCall()); + } + + async cancelActiveCall(): Promise { + const call = this.currentCall(); + if (!this.isActiveCall(call)) { + this.requestPending.set(false); + this.createPromptSubmitted.set(false); + return true; + } + + this.stopPolling(); + this.requestPending.set(false); + + try { + const cancelledCall = await firstValueFrom(this.assistant.cancelCall(call.id).pipe(take(1))); + this.currentCall.set(cancelledCall); + this.createPromptSubmitted.set(false); + this.persistSnapshot(); + + const session = await firstValueFrom(this.assistant.getSession(cancelledCall.sessionId || call.sessionId).pipe(take(1))); + this.applySessionState(session, { syncDraftToEditor: false }); + this.currentCall.set(null); + this.persistSnapshot(); + return true; + } catch (err) { + console.error('Assistant call cancel failed', err); + this.currentCall.set(call); + this.startPolling(call.id, call.sessionId); + this.assistantErrorMessage.set('Unable to cancel the assistant request.'); + this.persistSnapshot(); + return false; + } + } + + clearActiveSnapshot() { + const flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null); + this.clearSnapshot(flowKey); + } + private sendPrompt(content: string) { const normalizedContent = content.trim(); const sessionId = this.sessionState()?.id; @@ -325,10 +384,6 @@ export class FlowAssistant implements OnInit, OnDestroy { ? this.createModalFlowKey : this.resolveFlowKey(this.currentFlow()?.id ?? null); this.initialized = true; - if (this.isCreateModal()) { - void this.openSession(selectedModel, this.activeFlowKey); - return; - } void this.restoreSessionForFlow(this.activeFlowKey); }, error: (err) => { @@ -370,11 +425,14 @@ export class FlowAssistant implements OnInit, OnDestroy { next: (callState) => { this.currentCall.set(callState); this.persistSnapshot(); - if (callState.status === 'COMPLETED' || callState.status === 'FAILED') { + if (this.isTerminalCall(callState)) { this.stopPolling(); if (callState.status === 'COMPLETED' && callState.flowResult?.flow) { this.syncDraftToEditor(this.flowResultToDraft(callState.flowResult)); } + if (callState.status === 'CANCELLED') { + this.createPromptSubmitted.set(false); + } void this.reloadSession(sessionId, callState.status === 'FAILED'); } }, @@ -449,6 +507,7 @@ export class FlowAssistant implements OnInit, OnDestroy { const nextFlowKey = this.resolveFlowKey(nextFlow.id); this.persistSnapshot(currentFlowKey); this.sessionStore.cloneSnapshot(currentFlowKey, nextFlowKey); + const shouldClearCreateModalSnapshot = currentFlowKey === this.createModalFlowKey; void this.editorState.openDocument(nextFlow, { skipDirtyCheck: false }).then((opened) => { if (!opened) { @@ -456,6 +515,9 @@ export class FlowAssistant implements OnInit, OnDestroy { return; } this.editorState.loadAssistantFlow(nextFlow, { markDirty: true }); + if (shouldClearCreateModalSnapshot) { + this.clearSnapshot(currentFlowKey); + } }); } @@ -526,6 +588,11 @@ export class FlowAssistant implements OnInit, OnDestroy { return String(content ?? '').trim().replace(/\s+/g, ' '); } + private isTechnicalAssistantFailureMessage(message: AssistantChatMessage): boolean { + return message.role === 'assistant' + && /^the assistant request failed:/i.test(this.normalizeAssistantMessageContent(message.content)); + } + private canonicalAssistantErrorContent(content: string): string { return this.normalizeAssistantMessageContent(content) .replace(/^the assistant request failed:\s*/i, '') @@ -555,6 +622,9 @@ export class FlowAssistant implements OnInit, OnDestroy { this.quickPromptsOpen.set(true); this.sessionState.set(null); this.localMessages.set([]); + this.assistantErrorMessage.set(null); + this.lastFailedPrompt.set(null); + this.lastSubmittedPrompt.set(''); const model = this.selectedModel(); if (!model) { this.sessionLoading.set(false); @@ -568,7 +638,11 @@ export class FlowAssistant implements OnInit, OnDestroy { this.modelPickerOpen.set(snapshot.modelPickerOpen); this.quickPromptsOpen.set(snapshot.quickPromptsOpen); this.localMessages.set(snapshot.localMessages); - this.currentCall.set(null); + this.currentCall.set(snapshot.currentCall); + this.assistantErrorMessage.set(snapshot.assistantErrorMessage); + this.lastFailedPrompt.set(snapshot.lastFailedPrompt); + this.lastSubmittedPrompt.set(snapshot.lastSubmittedPrompt); + this.createPromptSubmitted.set(this.isCreateModal() && this.isActiveCall(snapshot.currentCall)); if (snapshot.selectedModel) { this.selectedModel.set(snapshot.selectedModel); } @@ -588,6 +662,12 @@ export class FlowAssistant implements OnInit, OnDestroy { return; } + if (this.isActiveCall(snapshot.currentCall)) { + this.sessionLoading.set(false); + this.startPolling(snapshot.currentCall.id, snapshot.currentCall.sessionId || snapshot.sessionId); + return; + } + this.sessionLoading.set(true); this.assistant.getSession(snapshot.sessionId).pipe( take(1), @@ -612,6 +692,21 @@ export class FlowAssistant implements OnInit, OnDestroy { return this.sessionStore.flowKey(flowId); } + private isActiveCall(call: AssistantCallState | null): call is AssistantCallState { + return call?.status === 'QUEUED' || call?.status === 'RUNNING'; + } + + private isTerminalCall(call: AssistantCallState): boolean { + return call.status === 'COMPLETED' || call.status === 'FAILED' || call.status === 'CANCELLED'; + } + + private clearSnapshot(flowKey: string) { + if (flowKey === this.activeFlowKey) { + this.skipDestroySnapshot = true; + } + this.sessionStore.clearSnapshot(flowKey); + } + private persistSnapshot(flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) { this.sessionStore.setSnapshot(flowKey, { sessionId: this.sessionState()?.id ?? null, @@ -621,7 +716,10 @@ export class FlowAssistant implements OnInit, OnDestroy { quickPromptsOpen: this.quickPromptsOpen(), localMessages: this.localMessages(), currentCall: this.currentCall(), - sessionState: this.sessionState() + sessionState: this.sessionState(), + assistantErrorMessage: this.assistantErrorMessage(), + lastFailedPrompt: this.lastFailedPrompt(), + lastSubmittedPrompt: this.lastSubmittedPrompt() }); } @@ -668,6 +766,8 @@ export class FlowAssistant implements OnInit, OnDestroy { return 'Finalizing flow...'; case 'failed': return 'Assistant request failed.'; + case 'cancelled': + return 'Assistant request cancelled.'; } } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index d3876c0..a996031 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -78,6 +78,10 @@ export class ReteEditor implements OnChanges, OnDestroy { } if (changes['flowData'] && this.rete) { setEditorGlobalInputs(this.rete.editor, this.flowData().globalInputs ?? []); + if (!this.readonly() && !this.isEditorGraphInSync()) { + void this.reloadEditor(); + return; + } } if (this.readonly() && changes['flowData']) { void this.syncReadonlyFlowData(); @@ -535,6 +539,11 @@ export class ReteEditor implements OnChanges, OnDestroy { this.flowChanged.emit(updatedData); } + private isEditorGraphInSync(): boolean { + if (!this.rete) return false; + return JSON.stringify(exportGraph(this.rete.editor)) === JSON.stringify(this.flowData()); + } + private canStartSelection(target: EventTarget | null) { if (!this.selectionModeActive) return false; const element = target instanceof HTMLElement ? target : null;