UI/UX: miglioramenti a flow-editor, flow-assistant e rete-editor (stili, layout, interazioni)

This commit is contained in:
Lucio Lelii 2026-05-18 12:35:09 +02:00
parent f558df3aa8
commit 73312f82c0
7 changed files with 300 additions and 40 deletions

View File

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

View File

@ -108,25 +108,55 @@
</div>
@if (showCreateAssistantModal()) {
<div class="flow-editor-create-ai-backdrop" (click)="closeCreateWithAi()"></div>
<div class="flow-editor-create-ai-backdrop"></div>
<section class="flow-editor-create-ai-modal" role="dialog" aria-modal="true" aria-labelledby="create-ai-title">
<div class="flow-editor-create-ai-header">
<div>
<div class="flow-editor-create-ai-eyebrow">Workflow Copilot</div>
<h2 id="create-ai-title" class="flow-editor-create-ai-title">New flow with AI</h2>
</div>
<button
type="button"
class="flow-editor-create-ai-close"
aria-label="Close AI flow creation"
(click)="closeCreateWithAi()">
<mat-icon fontIcon="close"></mat-icon>
</button>
<div class="flow-editor-create-ai-window-actions">
<span
[matTooltip]="createAssistantCancellable() ? 'Minimize and keep the assistant running' : 'Minimize is available while the assistant is running'">
<button
type="button"
class="flow-editor-create-ai-close"
aria-label="Minimize AI flow creation"
[disabled]="!createAssistantCancellable()"
(click)="minimizeCreateWithAi()">
<mat-icon fontIcon="remove"></mat-icon>
</button>
</span>
<button
type="button"
class="flow-editor-create-ai-close"
aria-label="Close AI flow creation"
[matTooltip]="createAssistantCancellable() ? 'Cancel the assistant request and close' : 'Close AI flow creation'"
(click)="closeCreateWithAi()">
<mat-icon fontIcon="close"></mat-icon>
</button>
</div>
</div>
<app-flow-assistant class="flow-editor-create-ai-assistant" [variant]="'create-modal'"></app-flow-assistant>
<app-flow-assistant
#createAssistant
class="flow-editor-create-ai-assistant"
[variant]="'create-modal'"
(cancellableCallChange)="createAssistantCancellable.set($event)"></app-flow-assistant>
</section>
}
@if (showCreateAssistantFloatingButton()) {
<button
type="button"
class="flow-editor-create-ai-floating"
aria-label="Reopen AI flow creation"
matTooltip="Reopen AI flow creation"
(click)="restoreCreateWithAi()">
<mat-icon fontIcon="auto_awesome"></mat-icon>
<span>AI flow creation</span>
</button>
}
@if (tourActive() && activeTourStep(); as step) {
<div class="flow-editor-tour-overlay" (click)="skipTour()"></div>
<div class="flow-editor-tour-spotlight" [ngStyle]="tourSpotlightStyle()"></div>

View File

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

View File

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

View File

@ -38,22 +38,6 @@
</section>
}
@if (assistantErrorMessage() && !assistantBusy()) {
<section class="assistant-card assistant-error-card" role="status" aria-live="polite">
<p class="assistant-error-message">{{ assistantErrorMessage() }}</p>
<button
type="button"
mat-stroked-button
class="assistant-retry"
[disabled]="!canRetryLastPrompt()"
(click)="retryLastPrompt()">
Retry
</button>
</section>
}
@if (!progressOnlyMode()) {
<section class="assistant-card assistant-starters">
<div class="assistant-starters-head">
@ -82,6 +66,16 @@
<article class="assistant-message" [class.assistant-message-user]="message.role === 'user'" [class.assistant-message-system]="message.role === 'system'">
<div class="assistant-message-role">{{ message.role }}</div>
<p class="assistant-message-body">{{ message.content }}</p>
@if (canRetryFromMessage(message)) {
<button
type="button"
class="assistant-inline-retry"
aria-label="Retry last assistant request"
[disabled]="!canRetryLastPrompt()"
(click)="retryLastPrompt()">
retry
</button>
}
@if (message.warnings?.length) {
<div class="assistant-list assistant-list-warning">

View File

@ -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<boolean>();
@ViewChild('assistantScroll') assistantScrollElement?: ElementRef<HTMLDivElement>;
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<AssistantConfig | null>(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<boolean> {
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.';
}
}

View File

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